Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3118,6 +3118,21 @@ class ContinuationToken(TypedDict):
# endregion


def _validate_structured_json(model: Any, text: str) -> Any:
"""Validate JSON against ``model`` with alias validation enabled.

Providers are asked for alias-keyed output (the model's JSON schema uses aliases), so aliases are
always accepted, even for a model configured with ``validate_by_alias=False``. The model's own
``validate_by_name`` / ``populate_by_name`` setting is preserved. Overriding ``by_alias`` needs
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)
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
Expand All @@ -3127,7 +3142,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_structured_json(response_format, text)
if isinstance(response_format, Mapping):
try:
return json.loads(text)
Expand Down
69 changes: 69 additions & 0 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7902,3 +7902,72 @@ 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 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


def test_agent_response_value_keeps_provider_value_for_defaulted_aliased_field() -> None:
"""A defaulted aliased field must not silently fall back to its default for alias-keyed output."""
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 Defaulted(BaseModel):
model_config = ConfigDict(validate_by_alias=False, validate_by_name=True)
item_count: int = Field(default=0, alias="itemCount")

response = AgentResponse(
messages=[Message(role="assistant", contents=['{"itemCount": 3}'])],
response_format=Defaulted,
)

assert response.value is not None
assert response.value.item_count == 3
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -189,6 +190,25 @@ 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 _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()
Expand Down Expand Up @@ -571,6 +591,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.

Expand Down Expand Up @@ -912,7 +939,14 @@ 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: Any = cast(Mapping[str, Any], options or {}).get(
"response_format"
) or self._default_options.get("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(
Expand Down Expand Up @@ -982,6 +1016,8 @@ 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")
response_schema = _to_response_schema(response_format)

input_messages = normalize_messages(messages)

Expand Down Expand Up @@ -1034,7 +1070,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_schema,
timeout=timeout,
)
except Exception as ex:
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
finally:
Expand Down Expand Up @@ -1066,6 +1107,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,
Comment thread
droideronline marked this conversation as resolved.
Resolved
)
session_context._response = response # type: ignore[assignment]
await self._run_after_providers(session=session, context=session_context)
Expand Down Expand Up @@ -1118,6 +1160,8 @@ 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")
response_schema = _to_response_schema(response_format)

input_messages = normalize_messages(messages)

Expand Down Expand Up @@ -1215,7 +1259,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"
Expand All @@ -1235,7 +1281,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_schema)
Comment thread
droideronline marked this conversation as resolved.
Resolved

while (item := await queue.get()) is not None:
if isinstance(item, Exception):
Expand Down Expand Up @@ -1790,6 +1836,7 @@ def _build_session_kwargs(
"on_pre_tool_use",
"on_function_approval",
"timeout",
"response_format",
"cli_path",
"log_level",
"base_directory",
Expand Down
Loading
Loading