From 7c60caa23f42135c37cb53913394e2b7a1427cab Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Wed, 25 Mar 2026 14:14:17 -0700 Subject: [PATCH 1/2] 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> --- .../agent_framework_openai/_chat_client.py | 14 +++++++++++++ .../02-agents/declarative/inline_yaml.py | 14 +++++++------ .../github_copilot/github_copilot_basic.py | 18 ++++++++++++++++ .../github_copilot/github_copilot_with_mcp.py | 3 ++- .../github_copilot_with_session.py | 21 ++++++++++++++++++- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index b0d56ee26f..aa3cf23cad 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -636,6 +636,20 @@ def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[s if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + if "properties" in response_format: + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.") def _get_conversation_id( diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index b6d4c9ede4..016f75947f 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework.declarative import AgentFactory from azure.identity.aio import AzureCliCredential @@ -31,16 +32,17 @@ async def main(): model: id: =Env.AZURE_OPENAI_MODEL - connection: - kind: remote - endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT """ # create the agent from the yaml async with ( AzureCliCredential() as credential, - AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).create_agent_from_yaml( - yaml_definition - ) as agent, + AgentFactory( + client_kwargs={ + "credential": credential, + "project_endpoint": os.environ["FOUNDRY_PROJECT_ENDPOINT"], + }, + safe_mode=False, + ).create_agent_from_yaml(yaml_definition) as agent, ): response = await agent.run("What can you do for me?") print("Agent response:", response.text) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index 3a2c4c8eec..af59ae9b3b 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -19,6 +19,8 @@ from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field @@ -26,6 +28,19 @@ load_dotenv() +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -45,6 +60,7 @@ async def non_streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -61,6 +77,7 @@ async def streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -80,6 +97,7 @@ async def runtime_options_example() -> None: agent = GitHubCopilotAgent( instructions="Always respond in exactly 3 words.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index fde1e8b72e..081dd1b21f 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -69,9 +69,10 @@ async def main() -> None: print(f"Agent: {result1}\n") # Query that exercises the remote Microsoft Learn MCP server + # Remote MCP calls may take longer, so increase the timeout query2 = "Search Microsoft Learn for 'Azure Functions Python' and summarize the top result" print(f"User: {query2}") - result2 = await agent.run(query2) + result2 = await agent.run(query2, options={"timeout": 120}) print(f"Agent: {result2}\n") diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 644104dc35..758eccb015 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -14,9 +14,24 @@ from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from pydantic import Field +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -36,6 +51,7 @@ async def example_with_automatic_session_creation() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -50,7 +66,7 @@ async def example_with_automatic_session_creation() -> None: print(f"\nUser: {query2}") result2 = await agent.run(query2) print(f"Agent: {result2}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + print("Note: Each call creates a separate session, so the agent may not remember previous context.\n") async def example_with_session_persistence() -> None: @@ -60,6 +76,7 @@ async def example_with_session_persistence() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -96,6 +113,7 @@ async def example_with_existing_session_id() -> None: agent1 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent1: @@ -117,6 +135,7 @@ async def example_with_existing_session_id() -> None: agent2 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent2: From 31993dd363d25356254080ac18efec9900dabc8e Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Wed, 25 Mar 2026 15:09:31 -0700 Subject: [PATCH 2/2] 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> --- .../agent_framework_azure_ai/_shared.py | 21 ++++++ python/packages/azure-ai/tests/test_shared.py | 26 ++++++++ .../agent_framework_openai/_chat_client.py | 9 ++- .../tests/openai/test_openai_chat_client.py | 65 +++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 7f5f770e36..220640d270 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -571,4 +571,25 @@ def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, An if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.") diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index 5672561194..845638ceee 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -404,6 +404,32 @@ def test_convert_response_format_json_schema_missing_schema_raises() -> None: _convert_response_format({"type": "json_schema", "json_schema": {}}) +def test_convert_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}) + + assert result["type"] == "json_schema" + assert result["name"] == "MyOutput" + assert result["strict"] is True + assert result["schema"]["additionalProperties"] is False + assert "title" not in result["schema"] + + +def test_convert_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}}) + + assert result["name"] == "response" + + +def test_convert_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]}) + + assert result["type"] == "json_schema" + assert result["strict"] is True + + def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" tools = [ diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index aa3cf23cad..54322cb754 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -638,10 +638,17 @@ def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[s # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) # by wrapping them in the expected json_schema envelope. - if "properties" in response_format: + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): schema = dict(response_format) if schema.get("type") == "object" and "additionalProperties" not in schema: schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. name = str(schema.pop("title", None) or "response") return { "type": "json_schema", diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 3c09839594..d4e7b6c4a1 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1713,6 +1713,71 @@ def test_response_format_json_schema_missing_schema() -> None: client._prepare_response_and_text_format(response_format=response_format, text_config=None) +def test_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + fmt = text_config["format"] + assert fmt["type"] == "json_schema" + assert fmt["name"] == "MyOutput" + assert fmt["strict"] is True + assert fmt["schema"]["additionalProperties"] is False + assert "title" not in fmt["schema"] + + +def test_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["name"] == "response" + + +def test_response_format_raw_json_schema_preserves_additional_properties() -> None: + """Test raw JSON schema preserves existing additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": True} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["schema"]["additionalProperties"] is True + + +def test_response_format_raw_json_schema_non_object_type() -> None: + """Test raw JSON schema with non-object type does not inject additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "array", "items": {"type": "string"}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert "additionalProperties" not in text_config["format"]["schema"] + + +def test_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"anyOf": [{"type": "string"}, {"type": "number"}]} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "json_schema" + + def test_response_format_unsupported_type() -> None: """Test unsupported response_format type raises error.""" client = OpenAIChatClient(model="test-model", api_key="test-key")