Skip to content

Commit 6b47cdb

Browse files
giles17Copilot
andauthored
Python: Fix broken samples for GitHub Copilot, declarative, and Responses API (#4915)
* Python: Fix broken samples for GitHub Copilot, declarative, and Responses API - Add missing on_permission_request handler to github_copilot_basic and github_copilot_with_session samples (required by copilot SDK) - Increase timeout for remote MCP query in github_copilot_with_mcp sample - Soften session isolation claim in github_copilot_with_session sample - Fix inline_yaml sample: pass project_endpoint via client_kwargs instead of relying on YAML connection block (AzureAIClient expects project_endpoint, not endpoint) - Handle raw JSON schemas in Responses client _convert_response_format so declarative outputSchema works with the Responses API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve raw JSON schema detection heuristic and add tests - Broaden raw schema detection to handle anyOf, oneOf, allOf, $ref, $defs keywords and JSON Schema primitive types, not just 'properties' - Apply same raw schema handling to azure-ai _shared.py for consistency - Add unit tests for both openai and azure-ai response_format conversion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cc0cfaa commit 6b47cdb

8 files changed

Lines changed: 181 additions & 8 deletions

File tree

python/packages/azure-ai/agent_framework_azure_ai/_shared.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,4 +571,25 @@ def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, An
571571
if format_type in {"json_object", "text"}:
572572
return {"type": format_type}
573573

574+
# Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}})
575+
# by wrapping them in the expected json_schema envelope.
576+
# Detect by checking for JSON Schema primitive types or known schema keywords.
577+
json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"}
578+
json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"}
579+
if format_type in json_schema_primitive_types or (
580+
format_type is None and any(k in response_format for k in json_schema_keywords)
581+
):
582+
schema = dict(response_format)
583+
if schema.get("type") == "object" and "additionalProperties" not in schema:
584+
schema["additionalProperties"] = False
585+
# Pop title from schema since OpenAI strict mode rejects unknown keys;
586+
# use it as the schema name in the envelope instead.
587+
name = str(schema.pop("title", None) or "response")
588+
return {
589+
"type": "json_schema",
590+
"name": name,
591+
"schema": schema,
592+
"strict": True,
593+
}
594+
574595
raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.")

python/packages/azure-ai/tests/test_shared.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,32 @@ def test_convert_response_format_json_schema_missing_schema_raises() -> None:
404404
_convert_response_format({"type": "json_schema", "json_schema": {}})
405405

406406

407+
def test_convert_response_format_raw_json_schema_with_properties() -> None:
408+
"""Test raw JSON schema with properties is wrapped in json_schema envelope."""
409+
result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"})
410+
411+
assert result["type"] == "json_schema"
412+
assert result["name"] == "MyOutput"
413+
assert result["strict"] is True
414+
assert result["schema"]["additionalProperties"] is False
415+
assert "title" not in result["schema"]
416+
417+
418+
def test_convert_response_format_raw_json_schema_no_title() -> None:
419+
"""Test raw JSON schema without title defaults name to 'response'."""
420+
result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}})
421+
422+
assert result["name"] == "response"
423+
424+
425+
def test_convert_response_format_raw_json_schema_with_anyof() -> None:
426+
"""Test raw JSON schema with anyOf keyword is detected."""
427+
result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]})
428+
429+
assert result["type"] == "json_schema"
430+
assert result["strict"] is True
431+
432+
407433
def test_from_azure_ai_tools_mcp_approval_mode_always() -> None:
408434
"""Test from_azure_ai_tools converts MCP require_approval='always' to dict."""
409435
tools = [

python/packages/openai/agent_framework_openai/_chat_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,27 @@ def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[s
636636
if format_type in {"json_object", "text"}:
637637
return {"type": format_type}
638638

639+
# Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}})
640+
# by wrapping them in the expected json_schema envelope.
641+
# Detect by checking for JSON Schema primitive types or known schema keywords.
642+
json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"}
643+
json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"}
644+
if format_type in json_schema_primitive_types or (
645+
format_type is None and any(k in response_format for k in json_schema_keywords)
646+
):
647+
schema = dict(response_format)
648+
if schema.get("type") == "object" and "additionalProperties" not in schema:
649+
schema["additionalProperties"] = False
650+
# Pop title from schema since OpenAI strict mode rejects unknown keys;
651+
# use it as the schema name in the envelope instead.
652+
name = str(schema.pop("title", None) or "response")
653+
return {
654+
"type": "json_schema",
655+
"name": name,
656+
"schema": schema,
657+
"strict": True,
658+
}
659+
639660
raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.")
640661

641662
def _get_conversation_id(

python/packages/openai/tests/openai/test_openai_chat_client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,6 +1713,71 @@ def test_response_format_json_schema_missing_schema() -> None:
17131713
client._prepare_response_and_text_format(response_format=response_format, text_config=None)
17141714

17151715

1716+
def test_response_format_raw_json_schema_with_properties() -> None:
1717+
"""Test raw JSON schema with properties is wrapped in json_schema envelope."""
1718+
client = OpenAIChatClient(model="test-model", api_key="test-key")
1719+
1720+
response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}
1721+
1722+
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
1723+
1724+
assert text_config is not None
1725+
fmt = text_config["format"]
1726+
assert fmt["type"] == "json_schema"
1727+
assert fmt["name"] == "MyOutput"
1728+
assert fmt["strict"] is True
1729+
assert fmt["schema"]["additionalProperties"] is False
1730+
assert "title" not in fmt["schema"]
1731+
1732+
1733+
def test_response_format_raw_json_schema_no_title() -> None:
1734+
"""Test raw JSON schema without title defaults name to 'response'."""
1735+
client = OpenAIChatClient(model="test-model", api_key="test-key")
1736+
1737+
response_format = {"type": "object", "properties": {"x": {"type": "string"}}}
1738+
1739+
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
1740+
1741+
assert text_config is not None
1742+
assert text_config["format"]["name"] == "response"
1743+
1744+
1745+
def test_response_format_raw_json_schema_preserves_additional_properties() -> None:
1746+
"""Test raw JSON schema preserves existing additionalProperties."""
1747+
client = OpenAIChatClient(model="test-model", api_key="test-key")
1748+
1749+
response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": True}
1750+
1751+
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
1752+
1753+
assert text_config is not None
1754+
assert text_config["format"]["schema"]["additionalProperties"] is True
1755+
1756+
1757+
def test_response_format_raw_json_schema_non_object_type() -> None:
1758+
"""Test raw JSON schema with non-object type does not inject additionalProperties."""
1759+
client = OpenAIChatClient(model="test-model", api_key="test-key")
1760+
1761+
response_format = {"type": "array", "items": {"type": "string"}}
1762+
1763+
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
1764+
1765+
assert text_config is not None
1766+
assert "additionalProperties" not in text_config["format"]["schema"]
1767+
1768+
1769+
def test_response_format_raw_json_schema_with_anyof() -> None:
1770+
"""Test raw JSON schema with anyOf keyword is detected."""
1771+
client = OpenAIChatClient(model="test-model", api_key="test-key")
1772+
1773+
response_format = {"anyOf": [{"type": "string"}, {"type": "number"}]}
1774+
1775+
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
1776+
1777+
assert text_config is not None
1778+
assert text_config["format"]["type"] == "json_schema"
1779+
1780+
17161781
def test_response_format_unsupported_type() -> None:
17171782
"""Test unsupported response_format type raises error."""
17181783
client = OpenAIChatClient(model="test-model", api_key="test-key")

python/samples/02-agents/declarative/inline_yaml.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22
import asyncio
3+
import os
34

45
from agent_framework.declarative import AgentFactory
56
from azure.identity.aio import AzureCliCredential
@@ -31,16 +32,17 @@ async def main():
3132
3233
model:
3334
id: =Env.AZURE_OPENAI_MODEL
34-
connection:
35-
kind: remote
36-
endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT
3735
"""
3836
# create the agent from the yaml
3937
async with (
4038
AzureCliCredential() as credential,
41-
AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).create_agent_from_yaml(
42-
yaml_definition
43-
) as agent,
39+
AgentFactory(
40+
client_kwargs={
41+
"credential": credential,
42+
"project_endpoint": os.environ["FOUNDRY_PROJECT_ENDPOINT"],
43+
},
44+
safe_mode=False,
45+
).create_agent_from_yaml(yaml_definition) as agent,
4446
):
4547
response = await agent.run("What can you do for me?")
4648
print("Agent response:", response.text)

python/samples/02-agents/providers/github_copilot/github_copilot_basic.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,28 @@
1919

2020
from agent_framework import tool
2121
from agent_framework.github import GitHubCopilotAgent
22+
from copilot.generated.session_events import PermissionRequest
23+
from copilot.types import PermissionRequestResult
2224
from dotenv import load_dotenv
2325
from pydantic import Field
2426

2527
# Load environment variables from .env file
2628
load_dotenv()
2729

2830

31+
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
32+
"""Permission handler that prompts the user for approval."""
33+
print(f"\n[Permission Request: {request.kind}]")
34+
35+
if request.full_command_text is not None:
36+
print(f" Command: {request.full_command_text}")
37+
38+
response = input("Approve? (y/n): ").strip().lower()
39+
if response in ("y", "yes"):
40+
return PermissionRequestResult(kind="approved")
41+
return PermissionRequestResult(kind="denied-interactively-by-user")
42+
43+
2944
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
3045
# see samples/02-agents/tools/function_tool_with_approval.py
3146
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@@ -45,6 +60,7 @@ async def non_streaming_example() -> None:
4560
agent = GitHubCopilotAgent(
4661
instructions="You are a helpful weather agent.",
4762
tools=[get_weather],
63+
default_options={"on_permission_request": prompt_permission},
4864
)
4965

5066
async with agent:
@@ -61,6 +77,7 @@ async def streaming_example() -> None:
6177
agent = GitHubCopilotAgent(
6278
instructions="You are a helpful weather agent.",
6379
tools=[get_weather],
80+
default_options={"on_permission_request": prompt_permission},
6481
)
6582

6683
async with agent:
@@ -80,6 +97,7 @@ async def runtime_options_example() -> None:
8097
agent = GitHubCopilotAgent(
8198
instructions="Always respond in exactly 3 words.",
8299
tools=[get_weather],
100+
default_options={"on_permission_request": prompt_permission},
83101
)
84102

85103
async with agent:

python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,10 @@ async def main() -> None:
6969
print(f"Agent: {result1}\n")
7070

7171
# Query that exercises the remote Microsoft Learn MCP server
72+
# Remote MCP calls may take longer, so increase the timeout
7273
query2 = "Search Microsoft Learn for 'Azure Functions Python' and summarize the top result"
7374
print(f"User: {query2}")
74-
result2 = await agent.run(query2)
75+
result2 = await agent.run(query2, options={"timeout": 120})
7576
print(f"Agent: {result2}\n")
7677

7778

python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,24 @@
1414

1515
from agent_framework import tool
1616
from agent_framework.github import GitHubCopilotAgent
17+
from copilot.generated.session_events import PermissionRequest
18+
from copilot.types import PermissionRequestResult
1719
from pydantic import Field
1820

1921

22+
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
23+
"""Permission handler that prompts the user for approval."""
24+
print(f"\n[Permission Request: {request.kind}]")
25+
26+
if request.full_command_text is not None:
27+
print(f" Command: {request.full_command_text}")
28+
29+
response = input("Approve? (y/n): ").strip().lower()
30+
if response in ("y", "yes"):
31+
return PermissionRequestResult(kind="approved")
32+
return PermissionRequestResult(kind="denied-interactively-by-user")
33+
34+
2035
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
2136
# see samples/02-agents/tools/function_tool_with_approval.py
2237
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@@ -36,6 +51,7 @@ async def example_with_automatic_session_creation() -> None:
3651
agent = GitHubCopilotAgent(
3752
instructions="You are a helpful weather agent.",
3853
tools=[get_weather],
54+
default_options={"on_permission_request": prompt_permission},
3955
)
4056

4157
async with agent:
@@ -50,7 +66,7 @@ async def example_with_automatic_session_creation() -> None:
5066
print(f"\nUser: {query2}")
5167
result2 = await agent.run(query2)
5268
print(f"Agent: {result2}")
53-
print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n")
69+
print("Note: Each call creates a separate session, so the agent may not remember previous context.\n")
5470

5571

5672
async def example_with_session_persistence() -> None:
@@ -60,6 +76,7 @@ async def example_with_session_persistence() -> None:
6076
agent = GitHubCopilotAgent(
6177
instructions="You are a helpful weather agent.",
6278
tools=[get_weather],
79+
default_options={"on_permission_request": prompt_permission},
6380
)
6481

6582
async with agent:
@@ -96,6 +113,7 @@ async def example_with_existing_session_id() -> None:
96113
agent1 = GitHubCopilotAgent(
97114
instructions="You are a helpful weather agent.",
98115
tools=[get_weather],
116+
default_options={"on_permission_request": prompt_permission},
99117
)
100118

101119
async with agent1:
@@ -117,6 +135,7 @@ async def example_with_existing_session_id() -> None:
117135
agent2 = GitHubCopilotAgent(
118136
instructions="You are a helpful weather agent.",
119137
tools=[get_weather],
138+
default_options={"on_permission_request": prompt_permission},
120139
)
121140

122141
async with agent2:

0 commit comments

Comments
 (0)