From 87f1a8237ecc204aa5b497391e7c781155d0c50c Mon Sep 17 00:00:00 2001 From: droideronline Date: Thu, 29 Oct 2026 01:05:54 +0530 Subject: [PATCH 1/7] Python: Support response_format in GitHubCopilotAgent Forward response_format as response_schema to the Copilot SDK send and send_and_wait calls, keep it out of create_session kwargs, and parse AgentResponse.value for streaming and non-streaming runs. --- .../agent_framework_github_copilot/_agent.py | 24 ++++- .../tests/test_github_copilot_agent.py | 100 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4dfdd70894a..40ef5b1c5da 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -53,6 +53,7 @@ ) from agent_framework.exceptions import AgentException, ContentError from agent_framework.observability import AgentTelemetryLayer +from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -575,6 +576,13 @@ class GitHubCopilotOptions(TypedDict, total=False): telemetry: TelemetryConfig """OpenTelemetry configuration for the Copilot CLI process.""" + response_format: type[BaseModel] | Mapping[str, Any] + """Structured output format for the run: a Pydantic model type or a JSON Schema mapping. + + Forwarded to the Copilot SDK as ``response_schema`` so the provider constrains the + final answer to the schema, and the parsed result is exposed on ``AgentResponse.value``. + Streaming still yields text; the value is parsed once the stream completes.""" + on_pre_tool_use: PreToolUseHandler """Pre-tool-use hook handler for the Copilot SDK. @@ -916,7 +924,8 @@ async def _after_run_hook(response: AgentResponse) -> None: logger.exception("Error running after_run providers in streaming result hook") def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: - return AgentResponse.from_updates(updates) + response_format = (options or {}).get("response_format") or self._default_options.get("response_format") + return AgentResponse.from_updates(updates, output_format_type=response_format) return ResponseStream( self._stream_updates( @@ -986,6 +995,7 @@ async def _run_impl( "together with on_permission_request instead." ) timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS + response_format = opts.get("response_format") or self._default_options.get("response_format") input_messages = normalize_messages(messages) @@ -1038,7 +1048,12 @@ def usage_event_handler(event: SessionEvent) -> None: unsubscribe = copilot_session.on(usage_event_handler) try: mark_feature_used(FeatureIndex.GITHUB_COPILOT) - response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout) + response_event = await copilot_session.send_and_wait( + prompt, + attachments=attachments, + response_schema=response_format, + timeout=timeout, + ) except Exception as ex: raise AgentException(f"GitHub Copilot request failed: {ex}") from ex finally: @@ -1070,6 +1085,7 @@ def usage_event_handler(event: SessionEvent) -> None: finish_reason=cast(Any, finish_reason), usage_details=usage_details, additional_properties={"model": model} if model else None, + response_format=response_format, ) session_context._response = response # type: ignore[assignment] await self._run_after_providers(session=session, context=session_context) @@ -1122,6 +1138,7 @@ async def _stream_updates( "(set via default_options). Remove on_function_approval and use on_pre_tool_use " "together with on_permission_request instead." ) + response_format = opts.get("response_format") or self._default_options.get("response_format") input_messages = normalize_messages(messages) @@ -1239,7 +1256,7 @@ def event_handler(event: SessionEvent) -> None: try: mark_feature_used(FeatureIndex.GITHUB_COPILOT) - await copilot_session.send(prompt, attachments=attachments) + await copilot_session.send(prompt, attachments=attachments, response_schema=response_format) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -1794,6 +1811,7 @@ def _build_session_kwargs( "on_pre_tool_use", "on_function_approval", "timeout", + "response_format", "cli_path", "log_level", "base_directory", diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index d6da1598b6c..e107480f641 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -17,6 +17,7 @@ from uuid import uuid4 import pytest +from pydantic import BaseModel copilot = pytest.importorskip("copilot") @@ -1213,6 +1214,105 @@ def mock_on(handler: Any) -> Any: assert responses[3].contents[0].type == "text" +class TestGitHubCopilotAgentStructuredOutput: + """Test cases for response_format (structured output) support.""" + + class Answer(BaseModel): + value: int + + async def test_run_passes_response_format_as_response_schema( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """response_format is forwarded to send_and_wait and parsed into AgentResponse.value.""" + mock_session.send_and_wait.return_value = create_session_event( + SessionEventType.ASSISTANT_MESSAGE, content='{"value": 42}', message_id="msg-1" + ) + + agent = GitHubCopilotAgent(client=mock_client) + response = await agent.run("What is 19 + 23?", options=cast(Any, {"response_format": self.Answer})) + + assert mock_session.send_and_wait.call_args.kwargs["response_schema"] is self.Answer + assert response.value == self.Answer(value=42) + + async def test_run_uses_default_options_response_format( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """response_format from default_options is used and not leaked into create_session.""" + mock_session.send_and_wait.return_value = create_session_event( + SessionEventType.ASSISTANT_MESSAGE, content='{"value": 7}', message_id="msg-1" + ) + + agent = GitHubCopilotAgent(client=mock_client, default_options=cast(Any, {"response_format": self.Answer})) + response = await agent.run("Hi") + + assert mock_session.send_and_wait.call_args.kwargs["response_schema"] is self.Answer + assert "response_format" not in mock_client.create_session.call_args.kwargs + assert response.value == self.Answer(value=7) + + async def test_run_accepts_json_schema_mapping( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """A JSON Schema mapping is forwarded unchanged.""" + schema = {"type": "object", "properties": {"value": {"type": "integer"}}, "required": ["value"]} + mock_session.send_and_wait.return_value = create_session_event( + SessionEventType.ASSISTANT_MESSAGE, content='{"value": 1}', message_id="msg-1" + ) + + agent = GitHubCopilotAgent(client=mock_client) + response = await agent.run("Hi", options=cast(Any, {"response_format": schema})) + + assert mock_session.send_and_wait.call_args.kwargs["response_schema"] == schema + assert response.value == {"value": 1} + + async def test_run_without_response_format_sends_no_schema( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """No schema is sent when response_format is not set.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GitHubCopilotAgent(client=mock_client) + await agent.run("Hi") + + assert mock_session.send_and_wait.call_args.kwargs["response_schema"] is None + + async def test_streaming_passes_response_format_and_parses_value( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Streaming forwards the schema to send and parses the final value after the stream.""" + events = [ + create_session_event(SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content='{"value": ', message_id="m"), + create_session_event(SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content="5}", message_id="m"), + create_session_event(SessionEventType.SESSION_IDLE), + ] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GitHubCopilotAgent(client=mock_client) + stream = agent.run("Hi", stream=True, options=cast(Any, {"response_format": self.Answer})) + async for _ in stream: + pass + response = await stream.get_final_response() + + assert mock_session.send.call_args.kwargs["response_schema"] is self.Answer + assert response.value == self.Answer(value=5) + + class TestGitHubCopilotAgentSessionManagement: """Test cases for session management.""" From 10fc98ef980762b382b71d9dccb62188ad2f7ff2 Mon Sep 17 00:00:00 2001 From: droideronline Date: Thu, 29 Oct 2026 19:00:33 +0530 Subject: [PATCH 2/7] Python: Address review feedback on GitHubCopilotAgent response_format Parse the streaming structured value from root-agent text only, convert mapping schemas to plain dicts before they reach the SDK, and validate Pydantic models by alias to match the schema the SDK requests. --- .../agent_framework_github_copilot/_agent.py | 65 ++++++++++++++- .../tests/test_github_copilot_agent.py | 82 ++++++++++++++++++- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 40ef5b1c5da..814024c99a3 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -194,6 +194,46 @@ async def _resolve_function_approval( logger = logging.getLogger("agent_framework.github_copilot") +def _to_response_schema(response_format: Any) -> Any: + """Return a value the Copilot SDK can JSON-serialize as ``response_schema``. + + Pydantic model classes are passed through unchanged. Any other mapping (for example a + ``MappingProxyType`` or ``UserDict``) is converted, recursively, to plain ``dict``/``list`` objects. + """ + if response_format is None or isinstance(response_format, type): + return response_format + + def _plain(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _plain(item) for key, item in cast(Mapping[Any, Any], value).items()} + if isinstance(value, (list, tuple)): + return [_plain(item) for item in cast(Sequence[Any], value)] + return value + + return _plain(response_format) + + +def _parse_structured_value(text: str, response_format: Any) -> Any: + """Parse the root assistant answer the same way the Copilot SDK's typed helpers do. + + Pydantic models are validated using the schema's aliases only, which is what the SDK asks + the provider to produce, including for nested models. + """ + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return response_format.model_validate_json(text, by_alias=True, by_name=False) + return json.loads(text) + + +def _try_parse_structured_value(text: str | None, response_format: Any) -> Any | None: + """Parse ``text`` or return ``None`` so the response falls back to its own lazy parsing and error.""" + if response_format is None or not text: + return None + try: + return _parse_structured_value(text, response_format) + except Exception: + return None + + def _provider_service_session_state_keys(agent: object, client: object | None) -> frozenset[str]: """Return provider-owned session-state keys declared by the Copilot agent or client.""" keys: set[str] = set() @@ -925,7 +965,11 @@ async def _after_run_hook(response: AgentResponse) -> None: def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: response_format = (options or {}).get("response_format") or self._default_options.get("response_format") - return AgentResponse.from_updates(updates, output_format_type=response_format) + return AgentResponse.from_updates( + updates, + output_format_type=response_format, + value=_try_parse_structured_value(ctx_holder.get("root_text"), response_format), + ) return ResponseStream( self._stream_updates( @@ -996,6 +1040,7 @@ async def _run_impl( ) timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS response_format = opts.get("response_format") or self._default_options.get("response_format") + response_schema = _to_response_schema(response_format) input_messages = normalize_messages(messages) @@ -1051,7 +1096,7 @@ def usage_event_handler(event: SessionEvent) -> None: response_event = await copilot_session.send_and_wait( prompt, attachments=attachments, - response_schema=response_format, + response_schema=response_schema, timeout=timeout, ) except Exception as ex: @@ -1086,6 +1131,9 @@ def usage_event_handler(event: SessionEvent) -> None: usage_details=usage_details, additional_properties={"model": model} if model else None, response_format=response_format, + value=_try_parse_structured_value( + response_messages[-1].text if response_messages else None, response_format + ), ) session_context._response = response # type: ignore[assignment] await self._run_after_providers(session=session, context=session_context) @@ -1139,6 +1187,7 @@ async def _stream_updates( "together with on_permission_request instead." ) response_format = opts.get("response_format") or self._default_options.get("response_format") + response_schema = _to_response_schema(response_format) input_messages = normalize_messages(messages) @@ -1157,11 +1206,19 @@ async def _stream_updates( attachments = self._prepare_attachments_for_copilot(context_messages) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() + # Text of each root-agent assistant message, so structured output is parsed from the root answer + # and never from sub-agent text interleaved in the same stream. + root_text: dict[str, str] = {} + last_root_message_id: str | None = None def event_handler(event: SessionEvent) -> None: + nonlocal last_root_message_id if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: data: Any = event.data if data.delta_content: + if not getattr(event, "agent_id", None): + root_text[data.message_id] = root_text.get(data.message_id, "") + data.delta_content + last_root_message_id = data.message_id update = AgentResponseUpdate( role="assistant", contents=[Content.from_text(data.delta_content, merge_mode="append")], @@ -1256,7 +1313,7 @@ def event_handler(event: SessionEvent) -> None: try: mark_feature_used(FeatureIndex.GITHUB_COPILOT) - await copilot_session.send(prompt, attachments=attachments, response_schema=response_format) + await copilot_session.send(prompt, attachments=attachments, response_schema=response_schema) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -1264,6 +1321,8 @@ def event_handler(event: SessionEvent) -> None: yield item finally: unsubscribe() + if _ctx_holder is not None and last_root_message_id is not None: + _ctx_holder["root_text"] = root_text.get(last_root_message_id) async def _run_before_providers( self, diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index e107480f641..32f40136ed0 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -11,13 +11,13 @@ from collections.abc import Sequence from datetime import datetime, timezone from pathlib import Path -from types import SimpleNamespace +from types import MappingProxyType, SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field, ValidationError copilot = pytest.importorskip("copilot") @@ -1284,6 +1284,84 @@ async def test_run_without_response_format_sends_no_schema( assert mock_session.send_and_wait.call_args.kwargs["response_schema"] is None + async def test_run_converts_mapping_schema_to_plain_dict( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Non-dict mappings are converted to plain dicts so the SDK can serialize them.""" + schema = MappingProxyType({ + "type": "object", + "properties": MappingProxyType({"value": MappingProxyType({"type": "integer"})}), + }) + mock_session.send_and_wait.return_value = create_session_event( + SessionEventType.ASSISTANT_MESSAGE, content='{"value": 1}', message_id="msg-1" + ) + + agent = GitHubCopilotAgent(client=mock_client) + await agent.run("Hi", options=cast(Any, {"response_format": schema})) + + sent = mock_session.send_and_wait.call_args.kwargs["response_schema"] + assert type(sent) is dict + assert type(sent["properties"]["value"]) is dict + json.dumps(sent) + + async def test_run_parses_aliased_model_by_alias( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Aliased models are validated by alias only, matching the schema sent to the SDK.""" + + class Aliased(BaseModel): + model_config = ConfigDict(validate_by_alias=False, validate_by_name=True) + item_count: int = Field(alias="itemCount") + + mock_session.send_and_wait.return_value = create_session_event( + SessionEventType.ASSISTANT_MESSAGE, content='{"itemCount": 3}', message_id="msg-1" + ) + # Core validation with the model's own settings would fail here. + with pytest.raises(ValidationError): + Aliased.model_validate_json('{"itemCount": 3}') + + agent = GitHubCopilotAgent(client=mock_client) + response = await agent.run("Hi", options=cast(Any, {"response_format": Aliased})) + + assert response.value == Aliased(item_count=3) + + async def test_streaming_value_ignores_trailing_subagent_text( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """A sub-agent delta after the root answer does not replace the structured value.""" + child = create_session_event( + SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content="child chatter", message_id="child" + ) + child.agent_id = "sub-agent" + events = [ + create_session_event( + SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content='{"value": 9}', message_id="root" + ), + child, + create_session_event(SessionEventType.SESSION_IDLE), + ] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GitHubCopilotAgent(client=mock_client) + stream = agent.run("Hi", stream=True, options=cast(Any, {"response_format": self.Answer})) + texts = [u.text async for u in stream] + response = await stream.get_final_response() + + assert "child chatter" in "".join(texts) + assert response.value == self.Answer(value=9) + async def test_streaming_passes_response_format_and_parses_value( self, mock_client: MagicMock, From a14d0ce18c212a6dd0f7f16b48ff47fa3edf66f8 Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 30 Oct 2026 13:38:13 +0530 Subject: [PATCH 3/7] Python: Fix pyright errors in GitHubCopilotAgent streaming finalizer --- .../github_copilot/agent_framework_github_copilot/_agent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 814024c99a3..21651ee7a1b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -964,7 +964,9 @@ async def _after_run_hook(response: AgentResponse) -> None: logger.exception("Error running after_run providers in streaming result hook") def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: - response_format = (options or {}).get("response_format") or self._default_options.get("response_format") + response_format: Any = cast(Mapping[str, Any], options or {}).get( + "response_format" + ) or self._default_options.get("response_format") return AgentResponse.from_updates( updates, output_format_type=response_format, From 790ac8e0600920e8549e43d8f71f8a6cc93ef834 Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 30 Oct 2026 13:42:20 +0530 Subject: [PATCH 4/7] Python: Move alias-based structured output parsing into core response classes --- .../packages/core/agent_framework/_types.py | 14 +++++- python/packages/core/tests/core/test_types.py | 19 ++++++++ .../agent_framework_github_copilot/_agent.py | 44 +++---------------- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b7febaf4256..f17204bcf49 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3067,6 +3067,18 @@ class ContinuationToken(TypedDict): # endregion +def _validate_json_by_alias(model: Any, text: str) -> Any: + """Validate JSON against ``model`` using only field aliases, as in the schema sent to providers. + + The by_alias/by_name overrides need pydantic 2.11; earlier versions use the model's own settings. + """ + from pydantic import VERSION + + if tuple(int(part) for part in VERSION.split(".")[:2]) >= (2, 11): + return model.model_validate_json(text, by_alias=True, by_name=False) + return model.model_validate_json(text) + + def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None: if response_format is None: return None @@ -3076,7 +3088,7 @@ def _parse_structured_response_value(text: str, response_format: Any | None) -> from pydantic import BaseModel if issubclass(response_format, BaseModel): - return response_format.model_validate_json(text) + return _validate_json_by_alias(response_format, text) if isinstance(response_format, Mapping): try: return json.loads(text) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 626353676ed..97addcf7d2f 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -7721,3 +7721,22 @@ def test_data_uri_readers_agree_on_the_base64_marker(): payload_marker = f"data:image/png,x;base64,{png_b64}" with pytest.raises(ValueError, match="Data URI must use base64 encoding."): detect_media_type_from_base64(data_uri=payload_marker) + + +def test_agent_response_value_validates_by_alias_only() -> None: + """Structured output is validated with aliases only, matching the schema sent to providers.""" + from pydantic import VERSION, ConfigDict + + if tuple(int(part) for part in VERSION.split(".")[:2]) < (2, 11): + pytest.skip("validate_by_alias/validate_by_name require pydantic 2.11") + + class Aliased(BaseModel): + model_config = ConfigDict(validate_by_alias=False, validate_by_name=True) + item_count: int = Field(alias="itemCount") + + response = AgentResponse( + messages=[Message(role="assistant", contents=['{"itemCount": 3}'])], + response_format=Aliased, + ) + + assert response.value == Aliased(item_count=3) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 21651ee7a1b..b261bdb54df 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -213,27 +213,6 @@ def _plain(value: Any) -> Any: return _plain(response_format) -def _parse_structured_value(text: str, response_format: Any) -> Any: - """Parse the root assistant answer the same way the Copilot SDK's typed helpers do. - - Pydantic models are validated using the schema's aliases only, which is what the SDK asks - the provider to produce, including for nested models. - """ - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - return response_format.model_validate_json(text, by_alias=True, by_name=False) - return json.loads(text) - - -def _try_parse_structured_value(text: str | None, response_format: Any) -> Any | None: - """Parse ``text`` or return ``None`` so the response falls back to its own lazy parsing and error.""" - if response_format is None or not text: - return None - try: - return _parse_structured_value(text, response_format) - except Exception: - return None - - def _provider_service_session_state_keys(agent: object, client: object | None) -> frozenset[str]: """Return provider-owned session-state keys declared by the Copilot agent or client.""" keys: set[str] = set() @@ -967,11 +946,11 @@ def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: response_format: Any = cast(Mapping[str, Any], options or {}).get( "response_format" ) or self._default_options.get("response_format") - return AgentResponse.from_updates( - updates, - output_format_type=response_format, - value=_try_parse_structured_value(ctx_holder.get("root_text"), response_format), - ) + if response_format is not None: + # The structured value comes from the root agent's answer, so sub-agent updates + # (which still stream to the caller) are left out of the final response. + updates = [update for update in updates if not getattr(update.raw_representation, "agent_id", None)] + return AgentResponse.from_updates(updates, output_format_type=response_format) return ResponseStream( self._stream_updates( @@ -1133,9 +1112,6 @@ def usage_event_handler(event: SessionEvent) -> None: usage_details=usage_details, additional_properties={"model": model} if model else None, response_format=response_format, - value=_try_parse_structured_value( - response_messages[-1].text if response_messages else None, response_format - ), ) session_context._response = response # type: ignore[assignment] await self._run_after_providers(session=session, context=session_context) @@ -1208,19 +1184,11 @@ async def _stream_updates( attachments = self._prepare_attachments_for_copilot(context_messages) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() - # Text of each root-agent assistant message, so structured output is parsed from the root answer - # and never from sub-agent text interleaved in the same stream. - root_text: dict[str, str] = {} - last_root_message_id: str | None = None def event_handler(event: SessionEvent) -> None: - nonlocal last_root_message_id if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: data: Any = event.data if data.delta_content: - if not getattr(event, "agent_id", None): - root_text[data.message_id] = root_text.get(data.message_id, "") + data.delta_content - last_root_message_id = data.message_id update = AgentResponseUpdate( role="assistant", contents=[Content.from_text(data.delta_content, merge_mode="append")], @@ -1323,8 +1291,6 @@ def event_handler(event: SessionEvent) -> None: yield item finally: unsubscribe() - if _ctx_holder is not None and last_root_message_id is not None: - _ctx_holder["root_text"] = root_text.get(last_root_message_id) async def _run_before_providers( self, From 3d1e5c802a7168342d3448896320466e25e7c0ce Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 30 Oct 2026 14:39:00 +0530 Subject: [PATCH 5/7] Python: Fix typing errors in aliased structured output tests --- python/packages/core/tests/core/test_types.py | 3 ++- .../packages/github_copilot/tests/test_github_copilot_agent.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 97addcf7d2f..e26177fdf9d 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -7739,4 +7739,5 @@ class Aliased(BaseModel): response_format=Aliased, ) - assert response.value == Aliased(item_count=3) + assert response.value is not None + assert response.value.item_count == 3 diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 32f40136ed0..28048fcaf6b 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1327,7 +1327,8 @@ class Aliased(BaseModel): agent = GitHubCopilotAgent(client=mock_client) response = await agent.run("Hi", options=cast(Any, {"response_format": Aliased})) - assert response.value == Aliased(item_count=3) + assert response.value is not None + assert response.value.item_count == 3 async def test_streaming_value_ignores_trailing_subagent_text( self, From 565d65fc9e8f117d46cc21d92c475936a5817f00 Mon Sep 17 00:00:00 2001 From: droideronline Date: Mon, 2 Nov 2026 16:51:49 +0530 Subject: [PATCH 6/7] Python: End GitHubCopilotAgent stream only on the root agent's idle event --- .../agent_framework_github_copilot/_agent.py | 4 ++- .../tests/test_github_copilot_agent.py | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index b261bdb54df..8d209097868 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -1263,7 +1263,9 @@ def event_handler(event: SessionEvent) -> None: ) queue.put_nowait(update) elif event.type == SessionEventType.SESSION_IDLE: - queue.put_nowait(None) + # A sub-agent going idle must not end the run; only the root agent's idle does. + if not getattr(event, "agent_id", None): + queue.put_nowait(None) elif event.type == SessionEventType.SESSION_ERROR: error_data: Any = event.data error_msg = error_data.message or "Unknown error" diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 28048fcaf6b..5e3dd2e8bac 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1363,6 +1363,39 @@ def mock_on(handler: Any) -> Any: assert "child chatter" in "".join(texts) assert response.value == self.Answer(value=9) + async def test_streaming_ignores_subagent_idle_before_root_answer( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """A sub-agent idle event does not end the stream or drop the root structured answer.""" + child_idle = create_session_event(SessionEventType.SESSION_IDLE) + child_idle.agent_id = "sub-agent" + events = [ + create_session_event(SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content="preamble", message_id="pre"), + child_idle, + create_session_event( + SessionEventType.ASSISTANT_MESSAGE_DELTA, delta_content='{"value": 11}', message_id="root" + ), + create_session_event(SessionEventType.SESSION_IDLE), + ] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GitHubCopilotAgent(client=mock_client) + stream = agent.run("Hi", stream=True, options=cast(Any, {"response_format": self.Answer})) + texts = [u.text async for u in stream] + response = await stream.get_final_response() + + assert '{"value": 11}' in "".join(texts) + assert response.value is not None + assert response.value.value == 11 + async def test_streaming_passes_response_format_and_parses_value( self, mock_client: MagicMock, From 6cdf1e0862b8818b2548de1f9f987ceb3f229b4b Mon Sep 17 00:00:00 2001 From: droideronline Date: Tue, 3 Nov 2026 16:44:58 +0530 Subject: [PATCH 7/7] Python: Preserve model field-name validation in structured output parsing --- .../packages/core/agent_framework/_types.py | 24 ++++++++++----- python/packages/core/tests/core/test_types.py | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index e4f523884d0..2c209a2918e 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3118,16 +3118,24 @@ class ContinuationToken(TypedDict): # endregion -def _validate_json_by_alias(model: Any, text: str) -> Any: - """Validate JSON against ``model`` using only field aliases, as in the schema sent to providers. +def _validate_structured_json(model: Any, text: str) -> Any: + """Validate JSON against ``model``, preserving the model's own field-name validation settings. - The by_alias/by_name overrides need pydantic 2.11; earlier versions use the model's own settings. + Providers are asked for alias-keyed output (the model's JSON schema uses aliases), which a model + configured with ``validate_by_alias=False`` rejects. When the model's own validation fails, retry + with aliases only (needs pydantic 2.11) and re-raise the original error if that also fails. """ - from pydantic import VERSION + from pydantic import VERSION, ValidationError - if tuple(int(part) for part in VERSION.split(".")[:2]) >= (2, 11): - return model.model_validate_json(text, by_alias=True, by_name=False) - return model.model_validate_json(text) + try: + return model.model_validate_json(text) + except ValidationError as exc: + if tuple(int(part) for part in VERSION.split(".")[:2]) < (2, 11): + raise + try: + return model.model_validate_json(text, by_alias=True, by_name=False) + except ValidationError: + raise exc from None def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None: @@ -3139,7 +3147,7 @@ def _parse_structured_response_value(text: str, response_format: Any | None) -> from pydantic import BaseModel if issubclass(response_format, BaseModel): - return _validate_json_by_alias(response_format, text) + return _validate_structured_json(response_format, text) if isinstance(response_format, Mapping): try: return json.loads(text) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index ca87cb3a4e7..ae896bc93f3 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -7922,3 +7922,32 @@ class Aliased(BaseModel): assert response.value is not None assert response.value.item_count == 3 + + +def test_agent_response_value_accepts_field_names_when_model_allows_it() -> None: + """A model configured to validate by field name keeps accepting name-keyed output.""" + from pydantic import ConfigDict + + class Named(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + item_count: int = Field(alias="itemCount") + + for text in ('{"item_count": 3}', '{"itemCount": 3}'): + response = AgentResponse( + messages=[Message(role="assistant", contents=[text])], + response_format=Named, + ) + assert response.value is not None + assert response.value.item_count == 3 + + +def test_agent_response_value_raises_original_error_for_invalid_output() -> None: + class Strict(BaseModel): + count: int + + response = AgentResponse( + messages=[Message(role="assistant", contents=['{"count": "x"}'])], + response_format=Strict, + ) + with pytest.raises(ValidationError): + _ = response.value