mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
committed by
GitHub
Unverified
parent
cc0cfaaac8
commit
6b47cdbf52
@@ -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.")
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -636,6 +636,27 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
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 ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.")
|
||||
|
||||
def _get_conversation_id(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 @@ description: A agent that performs diagnostics on systems and can escalate issue
|
||||
|
||||
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)
|
||||
|
||||
@@ -19,6 +19,8 @@ from typing import Annotated
|
||||
|
||||
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 @@ from pydantic import Field
|
||||
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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -14,9 +14,24 @@ from typing import Annotated
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user