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
16 changes: 16 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,22 @@ agent_framework/
every output-capable executor not selected by `output_from`.
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
and Intermediate Output `get_intermediate_outputs()` accessors
- **Request-info trust boundary** - Pending workflow state is authoritative for request data and Python types.
For a plain workflow, retain the emitted typed event or use
`await workflow.get_pending_request_info(request_id)`. For a workflow agent, pass the complete emitted
function-call `Content` to `await agent.resolve_request_info(content)`; the resolver validates correlation and
compatibility metadata, then returns the workflow-held event without trusting copied request data. Both lookups
are non-consuming; successful response submission through `run(...)` consumes the pending request. Serialized
request and response type names in the current wire envelope are compatibility metadata only, not authorization
data. Out-of-process consumers that do not own the live workflow can use
`WorkflowEvent.rehydrate_request_info(...)` or `WorkflowAgent.RequestInfoFunctionArgs.rehydrate(...)`, supplying
`allowed_types` for custom or parameterized annotations that are not available from an already-loaded module.
Default module resolution reads only top-level types physically present in an already-loaded module namespace;
nested, function-local, lazily exported, and parameterized annotations require `allowed_types`. These transport-only
rehydration methods do not establish that a request is still pending. `WorkflowEvent.from_dict` and
`WorkflowAgent.RequestInfoFunctionArgs.from_dict` remain only as deprecated compatibility paths; migrate them to the
corresponding `rehydrate...` method for transport decoding or to the authoritative workflow/agent resolver when the
owning workflow is available.
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`

## Built-in Providers
Expand Down
134 changes: 132 additions & 2 deletions python/packages/core/agent_framework/_workflows/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from __future__ import annotations

import json
import logging
import sys
import uuid
import warnings
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
Expand Down Expand Up @@ -64,18 +66,69 @@ def to_dict(self) -> dict[str, Any]:
return {"request_id": self.request_id, "request_event": self.request_event.to_dict()}

@classmethod
def from_dict(cls, payload: dict[str, Any]) -> WorkflowAgent.RequestInfoFunctionArgs:
def rehydrate(
cls,
payload: Mapping[str, Any],
*,
allowed_types: Mapping[str, object] | None = None,
) -> WorkflowAgent.RequestInfoFunctionArgs:
"""Rehydrate request-info arguments from trusted transport data.

Args:
payload: Serialized request-info function arguments.

Keyword Args:
allowed_types: Optional mapping of serialized names to trusted custom types or typing annotations.

Returns:
The rehydrated request-info arguments.

Raises:
ValueError: If required request-info fields are missing or empty.
"""
if not isinstance(payload, Mapping):
raise ValueError("Serialized request-info arguments payload must be a mapping.")
if "request_id" not in payload or "request_event" not in payload:
raise ValueError(
"Invalid payload for RequestInfoFunctionArgs. 'request_id' and 'request_event' are required."
)
if not payload["request_id"]:
raise ValueError("request_id cannot be empty.")
request_event = payload["request_event"]
if not isinstance(request_event, Mapping):
raise ValueError("Serialized request-info field 'request_event' must be a mapping.")
request_event_mapping = cast(Mapping[str, Any], request_event)

return cls(
request_id=payload.get("request_id", ""),
request_event=WorkflowEvent.from_dict(payload.get("request_event", {})),
request_event=WorkflowEvent.rehydrate_request_info(
request_event_mapping,
allowed_types=allowed_types,
),
)

@classmethod
def from_dict(
cls,
payload: dict[str, Any],
*,
allowed_types: Mapping[str, object] | None = None,
) -> WorkflowAgent.RequestInfoFunctionArgs:
"""Reconstruct request-info arguments with optional trusted custom types.

Deprecated:
Use :meth:`rehydrate` for transport decoding or
:meth:`WorkflowAgent.resolve_request_info` with a live workflow.
"""
warnings.warn(
"`WorkflowAgent.RequestInfoFunctionArgs.from_dict` is deprecated and will be removed "
"in a future version; use `WorkflowAgent.RequestInfoFunctionArgs.rehydrate` for transport "
"decoding or `WorkflowAgent.resolve_request_info` "
"(`await agent.resolve_request_info(content)`) with a live workflow instead.",
DeprecationWarning,
stacklevel=2,
)
return cls.rehydrate(payload, allowed_types=allowed_types)

def __init__(
self,
Expand Down Expand Up @@ -130,6 +183,83 @@ def __init__(
def workflow(self) -> Workflow:
return self._workflow

async def resolve_request_info(self, content: Content) -> WorkflowEvent[Any]:
"""Resolve a request-info function call against the wrapped workflow's pending state.

Copied request data is ignored. Correlation and compatibility metadata are
validated against the authoritative event retained by the workflow.

Args:
content: Complete request-info function-call content emitted by this agent.

Returns:
The original pending request-info event retained by the workflow.

Raises:
ValueError: If the content is malformed, forged, stale, or replayed.
TypeError: If the pending request contains an unsupported wire type annotation.
"""
if not isinstance(content, Content):
raise ValueError("Request-info content must be a Content instance.")
content_type = content.type
if type(content_type) is not str or content_type != "function_call":
raise ValueError("Request-info content type must be 'function_call'.")

function_name = content.name
if type(function_name) is not str or function_name != self.REQUEST_INFO_FUNCTION_NAME:
raise ValueError(f"Request-info function-call field 'name' must be {self.REQUEST_INFO_FUNCTION_NAME!r}.")

if isinstance(content.arguments, str):
try:
arguments = json.loads(content.arguments)
except json.JSONDecodeError:
raise ValueError("Request-info function-call field 'arguments' must be a JSON object.") from None
else:
arguments = content.parse_arguments()
if not isinstance(arguments, Mapping):
raise ValueError("Request-info function-call field 'arguments' must be a JSON object.")
arguments_mapping = cast(Mapping[str, Any], arguments)
arguments_dict = dict(arguments_mapping)

call_id = content.call_id
if type(call_id) is not str or not call_id:
raise ValueError("Request-info function-call field 'call_id' must be a non-empty string.")

request_id = arguments_dict.get("request_id")
if type(request_id) is not str or not request_id:
raise ValueError("Request-info function-call field 'arguments.request_id' must be a non-empty string.")

request_event_value = arguments_dict.get("request_event")
if not isinstance(request_event_value, Mapping):
raise ValueError("Request-info function-call field 'request_event' must be a JSON object.")
request_event_mapping = cast(Mapping[str, Any], request_event_value)
request_event = dict(request_event_mapping)

event_request_id = request_event.get("request_id")
if type(event_request_id) is not str or not event_request_id:
raise ValueError("Request-info function-call field 'request_event.request_id' must be a non-empty string.")
if call_id != request_id or request_id != event_request_id:
raise ValueError(
"Request-info correlation IDs in fields 'call_id', 'arguments.request_id', "
"and 'request_event.request_id' must match."
)

pending_event = await self._workflow.get_pending_request_info(request_id)
expected_arguments = self.RequestInfoFunctionArgs(
request_id=request_id,
request_event=pending_event,
).to_dict()
expected_request_event = cast(dict[str, Any], expected_arguments["request_event"])
for field in ("type", "source_executor_id", "request_type", "response_type"):
expected_value = expected_request_event[field]
actual_value = request_event.get(field)
if type(actual_value) is not str or actual_value != expected_value:
raise ValueError(
f"Request-info function-call field 'request_event.{field}' does not match the pending request."
)

return pending_event

# region Run Methods

@overload
Expand Down
70 changes: 59 additions & 11 deletions python/packages/core/agent_framework/_workflows/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
import traceback as _traceback
import warnings
from collections.abc import Generator
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
Expand Down Expand Up @@ -426,23 +426,71 @@ def to_dict(self) -> dict[str, Any]:
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent[Any]:
"""Create a REQUEST_INFO event from a dictionary."""
def rehydrate_request_info(
cls,
data: Mapping[str, Any],
*,
allowed_types: Mapping[str, object] | None = None,
) -> WorkflowEvent[Any]:
"""Rehydrate a request-info event from trusted transport data.

Args:
data: Serialized request-info event fields.
allowed_types: Optional mapping of serialized names to trusted custom types or typing annotations.
Use this for nested, function-local, lazily exported, or parameterized annotations that cannot be
resolved as top-level types from an already-loaded module namespace.

Returns:
The rehydrated request-info event.

Raises:
ValueError: If the request-info event data is not a mapping.
KeyError: If a required request-info field is missing.
TypeError: If the request data type does not match its serialized metadata.
ModuleNotFoundError: If a response type's module is not already loaded.
AttributeError: If a loaded response-type module does not contain the serialized top-level type.
"""
if not isinstance(data, Mapping):
raise ValueError("Serialized request-info event data must be a mapping.")

for prop in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
if prop not in data:
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")

request_data = data["data"]
request_type = deserialize_type(data["request_type"])

if request_type is not type(request_data):
raise TypeError(
"Mismatch between deserialized request_data type and request_type field in WorkflowEvent dictionary."
)
request_type = cast(builtins.type[Any], type(request_data))
if serialize_type(request_type) != data["request_type"]:
raise TypeError("Mismatch between request_data type and request_type field in WorkflowEvent dictionary.")

return cls.request_info(
request_id=data["request_id"],
source_executor_id=data["source_executor_id"],
request_data=cast(Any, request_data), # type: ignore
response_type=deserialize_type(data["response_type"]),
response_type=deserialize_type(data["response_type"], allowed_types=allowed_types),
)

@classmethod
def from_dict(
cls,
data: dict[str, Any],
*,
allowed_types: Mapping[str, object] | None = None,
) -> WorkflowEvent[Any]:
"""Create a REQUEST_INFO event from a dictionary.

Deprecated:
Use :meth:`rehydrate_request_info` for transport decoding or
``Workflow.get_pending_request_info`` for authoritative lookup.

Args:
data: Serialized request-info event fields.
allowed_types: Optional mapping of serialized names to trusted custom types or typing annotations.
"""
warnings.warn(
"`WorkflowEvent.from_dict` is deprecated and will be removed in a future version; "
"use `WorkflowEvent.rehydrate_request_info` for transport decoding or "
"`Workflow.get_pending_request_info` (`await workflow.get_pending_request_info(request_id)`) "
"for authoritative lookup instead.",
DeprecationWarning,
stacklevel=2,
)
return cls.rehydrate_request_info(data, allowed_types=allowed_types)
Loading
Loading