diff --git a/python/packages/ag-ui/AGENTS.md b/python/packages/ag-ui/AGENTS.md index 656a3fa77f1..ff35e4520a4 100644 --- a/python/packages/ag-ui/AGENTS.md +++ b/python/packages/ag-ui/AGENTS.md @@ -16,7 +16,7 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard. - **`AGUIRequest`** / **`AGUIChatOptions`** - Request types - **`AGUIThreadSnapshot`** / **`AGUIThreadSnapshotStore`** - Replayable thread snapshot model and scoped async store protocol -- **`availableInterrupts` / `resume`** - Optional interrupt configuration and continuation payloads +- **`availableInterrupts` / `resume`** - Optional canonical AG-UI `Interrupt` and `ResumeEntry` protocol data - **`AgentState`** / **`RunMetadata`** - State management types - **`PredictStateConfig`** - Configuration for state prediction @@ -26,7 +26,8 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard. - Usage metadata from `Content(type="usage")` is surfaced as `CUSTOM` events with `name="usage"`. - Inbound custom event aliases are accepted: `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`. - Multimodal user inputs support both legacy (`text`, `binary`) and draft-style (`image`, `audio`, `video`, `document`) shapes. -- `RUN_FINISHED.interrupt` can be emitted for pause/request-info flows, and interruption metadata is preserved in converters. +- Interrupted runs complete with `RUN_FINISHED.outcome.type == "interrupt"` and canonical `outcome.interrupts`; do not document or add new flows that depend on the legacy top-level `RUN_FINISHED.interrupt` field. +- `Interrupt` and `ResumeEntry` come from the `ag-ui-protocol` package (`ag_ui.core`), not from an Agent Framework-specific interrupt model. ## Usage diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index 0119aa81889..d174e6ac15e 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -97,7 +97,7 @@ The `AGUIChatClient` supports: - Hybrid tool execution (client-side + server-side tools) - Automatic thread management for conversation continuity - Integration with `Agent` for client-side history management -- Interrupt metadata passthrough (`availableInterrupts` and `resume`) +- Canonical interrupt/resume passthrough (`availableInterrupts` and `resume`) ## Tool Return Helpers @@ -132,6 +132,94 @@ async def get_weather(city: str) -> Content: - Thread management and conversation continuity - **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features +## Interrupts and Resume + +Agent Framework AG-UI uses the canonical AG-UI interrupt protocol. Paused agent approval and workflow +`request_info` runs finish with `RUN_FINISHED.outcome.type == "interrupt"` and a non-empty +`RUN_FINISHED.outcome.interrupts` array. Agent Framework does not define a separate interrupt model; use +`ag_ui.core.Interrupt` and `ag_ui.core.ResumeEntry` when constructing typed request data in Python. + +Tool approval interrupts use `reason: "tool_call"` and include `toolCallId` when the pause is bound to a tool call. +Workflow `request_info` interrupts use `reason: "input_required"`. Framework-specific details needed for resume +validation live in each interrupt's `metadata`, while generic clients can render the human-readable `message` and +`responseSchema`. + +Interrupted terminal event shape: + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [ + { + "id": "approval_1", + "reason": "tool_call", + "message": "Approve tool call get_weather?", + "toolCallId": "tool_call_1", + "responseSchema": { + "type": "object", + "properties": { + "accepted": { "type": "boolean" }, + "arguments": { "type": "object" } + }, + "required": ["accepted"] + }, + "metadata": { + "agent_framework": { + "type": "function_approval_request", + "function_call": { + "call_id": "tool_call_1", + "name": "get_weather", + "arguments": { + "city": "Seattle" + } + } + } + } + } + ] + } +} +``` + +Resume the paused thread with a canonical `resume` array. Each entry addresses exactly one open interrupt by +`interruptId`; `status` is `resolved` or `cancelled`; resolved entries carry the approval or workflow response payload. + +```json +{ + "threadId": "thread-1", + "messages": [], + "resume": [ + { + "interruptId": "approval_1", + "status": "resolved", + "payload": { + "approved": true + } + } + ] +} +``` + +This is a clean release-candidate breaking change before `1.0.0`: new interrupted runs use +`RUN_FINISHED.outcome.interrupts` and do not emit a stable top-level `RUN_FINISHED.interrupt` field. Normal +non-interrupted runs continue to finish with valid `RUN_FINISHED` terminal events. + +## Public API Review Notes + +The Python package is currently in release candidate stage and is targeting the released `1.0.0` API surface. The preferred application import path is `agent_framework.ag_ui`; direct package imports from `agent_framework_ag_ui` are also supported. + +Review focus: whether these names are the right stable contract for Python users, and whether the protocol interrupt fields below match AG-UI's expected pause/resume shape. + +| Surface | Public exports | +| --- | --- | +| `agent_framework.ag_ui` facade | `AgentFrameworkAgent`, `AgentFrameworkWorkflow`, `AGUIChatClient`, `AGUIEventConverter`, `AGUIHttpService`, `AGUIThreadSnapshot`, `AGUIThreadSnapshotStore`, `InMemoryAGUIThreadSnapshotStore`, `SnapshotScopeResolver`, `add_agent_framework_fastapi_endpoint`, `state_update`, `__version__` | +| Direct `agent_framework_ag_ui` package | Facade exports plus `AGUIChatOptions`, `AGUIRequest`, `AGUIThreadID`, `AgentState`, `DEFAULT_MAX_THREAD_SNAPSHOTS`, `DEFAULT_TAGS`, `PredictStateConfig`, `RunMetadata`, `SnapshotScope`, `WorkflowFactory` | +| AG-UI protocol package (`ag_ui.core`) | `Interrupt`, `ResumeEntry`, `RunFinishedInterruptOutcome`, and related run outcome models | + +Interrupt support is protocol data rather than a separate Agent Framework Python class. Requests accept canonical `availableInterrupts`/`available_interrupts` and `resume` values; `AGUIChatClient` and `AGUIHttpService.post_run(...)` forward those fields with AG-UI wire aliases; agent approval and workflow `request_info` pauses emit `RUN_FINISHED.outcome.interrupts`; `AGUIEventConverter` preserves canonical interrupt outcome metadata on the final `ChatResponseUpdate`; and thread snapshot hydration replays the canonical interrupt outcome when a scoped snapshot stores an unresolved pause. + ## Features This integration supports all 7 AG-UI features: @@ -149,7 +237,7 @@ Additional compatibility and draft support: - Workflow-to-AG-UI event mapping (run/step/activity/tool/custom events) - Custom event compatibility for inbound `CUSTOM`, `CUSTOM_EVENT`, and `custom_event` - Pragmatic multimodal input parsing for both legacy (`binary`) and draft media-part shapes -- Pragmatic interrupt/resume handling (`availableInterrupts`, `resume`, and `RUN_FINISHED.interrupt`) +- Canonical interrupt/resume handling (`availableInterrupts`, `resume`, and `RUN_FINISHED.outcome.interrupts`) ## Security: Authentication & Authorization @@ -206,7 +294,7 @@ unless you provide a `snapshot_store`. Thread snapshots let an AG-UI frontend recover replayable UI state after a refresh. When snapshot persistence is enabled, the endpoint stores the latest replayable snapshot for an AG-UI Thread within an application-defined Snapshot Scope. A Hydrate Request is an AG-UI request with a known `threadId`, `messages: []`, and no `resume` -payload. Hydration replays the stored Shared State, message snapshot, and interruption metadata when available, +payload. Hydration replays the stored Shared State, message snapshot, and canonical interrupt outcome when available, then finishes without invoking the wrapped agent or workflow. Use the built-in in-memory store for local development, demos, and tests: @@ -255,7 +343,7 @@ and choose a Snapshot Scope that represents the app's real access boundary, such or workspace. Do not rely on untrusted client-provided fields by themselves to choose that boundary. Stored snapshots are untrusted application data with confidentiality impact. They may contain sensitive user text, -model output, tool results, function arguments, UI payloads, Shared State, and interruption data. The built-in +model output, tool results, function arguments, UI payloads, Shared State, and interrupt data. The built-in `InMemoryAGUIThreadSnapshotStore` is in-memory only, process-local, bounded, latest-only, and not durable production storage. It is cleared on process restart and is not shared across workers. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py index 191de68c5f3..e258676b63b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -9,7 +9,7 @@ from ag_ui.core import BaseEvent from agent_framework import SupportsAgentRun -from ._agent_run import PendingApprovalEntry, run_agent_stream +from ._agent_run import PendingApprovalEntry, PendingApprovalKey, run_agent_stream from ._snapshots import AGUIThreadSnapshotStore @@ -112,11 +112,11 @@ def __init__( ) # Server-side registry of pending approval requests. - # Keys are "{thread_id}:{request_id}", values are the function name. + # Keys are (thread_id, request_id), values are the function name. # Populated when approval requests are emitted; consumed when responses arrive. # Prevents bypass, function name spoofing, and replay attacks. # Bounded to prevent unbounded growth from abandoned approval requests. - self._pending_approvals: OrderedDict[str, PendingApprovalEntry] = OrderedDict() + self._pending_approvals: OrderedDict[PendingApprovalKey, PendingApprovalEntry] = OrderedDict() self._pending_approvals_max_size: int = 10_000 @property diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 6f1174f0014..b029d3cb182 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -15,6 +15,7 @@ BaseEvent, CustomEvent, MessagesSnapshotEvent, + RunErrorEvent, RunStartedEvent, StateSnapshotEvent, TextMessageContentEvent, @@ -46,6 +47,8 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register_additional_client_tools from ._run_common import ( FlowState, + _approval_interrupt_for_function_call, # type: ignore + _approval_steps_response_schema, # type: ignore _build_run_finished_event, # type: ignore _close_reasoning_block, # type: ignore _emit_content, # type: ignore @@ -54,10 +57,16 @@ _has_only_tool_calls, # type: ignore _normalize_resume_interrupts, # type: ignore _reconstruct_messages_from_thread_snapshot, # type: ignore + _resume_contract_error, # type: ignore _resolve_ui_payload, # type: ignore _stringify_tool_result, # type: ignore ) -from ._snapshots import AGUIThreadSnapshot, _DEFAULT_STATE_INPUT_KEY, _SNAPSHOT_SCOPE_INPUT_KEY +from ._snapshots import ( + _DEFAULT_STATE_INPUT_KEY, + _SNAPSHOT_SCOPE_INPUT_KEY, + AGUIThreadSnapshot, + _clear_thread_snapshot_interrupt, +) from ._utils import ( canonical_function_arguments, convert_agui_tools_to_agent_framework, @@ -174,10 +183,18 @@ def _extract_approved_state_updates( return updates -def _resume_to_tool_messages(resume_payload: Any) -> list[dict[str, Any]]: +def _resume_to_tool_messages( + resume_payload: Any, + *, + exclude_interrupt_ids: set[str] | None = None, +) -> list[dict[str, Any]]: """Convert a resume payload into AG-UI tool messages for approval continuation.""" result: list[dict[str, Any]] = [] for interrupt in _normalize_resume_interrupts(resume_payload): + if interrupt.get("status") not in {None, "resolved"}: + continue + if exclude_interrupt_ids and interrupt["id"] in exclude_interrupt_ids: + continue value = interrupt.get("value") content: str if isinstance(value, str): @@ -416,13 +433,27 @@ class _PendingApproval(TypedDict): name: str arguments: str | None + request_id: str | None + interrupt_id: str | None PendingApprovalEntry = _PendingApproval | str +PendingApprovalKey = tuple[str, str] + +def _pending_approval_key(thread_id: str, interrupt_id: str) -> PendingApprovalKey: + """Build a structured pending-approval key scoped by thread and interrupt id.""" + return (thread_id, interrupt_id) -def _make_pending_approval_entry(name: str, arguments: str | None) -> _PendingApproval: - return {"name": name, "arguments": arguments} + +def _make_pending_approval_entry( + name: str, + arguments: str | None, + *, + request_id: str | None = None, + interrupt_id: str | None = None, +) -> _PendingApproval: + return {"name": name, "arguments": arguments, "request_id": request_id, "interrupt_id": interrupt_id} def _pending_approval_name(entry: PendingApprovalEntry) -> str | None: @@ -437,7 +468,328 @@ def _pending_approval_arguments(entry: PendingApprovalEntry) -> str | None: return entry["arguments"] -def _evict_oldest_approvals(registry: dict[str, PendingApprovalEntry], max_size: int = 10_000) -> None: +def _parse_json_object(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return cast(dict[str, Any], value) + if not isinstance(value, str) or not value: + return None + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None + + +def _thread_has_pending_approvals( + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None, + thread_id: str, +) -> bool: + if not pending_approvals: + return False + return any(key[0] == thread_id for key in pending_approvals) + + +def _pending_approval_interrupt_ids( + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None, + thread_id: str, +) -> set[str]: + if not pending_approvals: + return set() + interrupt_ids: set[str] = set() + for key, entry in pending_approvals.items(): + if key[0] != thread_id: + continue + if isinstance(entry, str): + interrupt_ids.add(key[1]) + continue + interrupt_id = entry.get("interrupt_id") or entry.get("request_id") or key[1] + interrupt_ids.add(str(interrupt_id)) + return interrupt_ids + + +def _stored_pending_approval_interrupt_ids(interrupts: list[dict[str, Any]] | None) -> set[str]: + """Return stored interrupt ids that require server-side approval registry validation.""" + if not interrupts: + return set() + interrupt_ids: set[str] = set() + for interrupt in interrupts: + metadata = interrupt.get("metadata") + if not isinstance(metadata, dict): + continue + agent_framework_metadata = metadata.get("agent_framework") + if not isinstance(agent_framework_metadata, dict): + continue + if agent_framework_metadata.get("type") != "function_approval_request": + continue + if agent_framework_metadata.get("confirmation_tool_call_id"): + continue + interrupt_id = interrupt.get("id") or interrupt.get("interruptId") + if interrupt_id: + interrupt_ids.add(str(interrupt_id)) + return interrupt_ids + + +def _find_pending_approval_entry( + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None, + thread_id: str, + interrupt_id: str, +) -> PendingApprovalEntry | None: + if pending_approvals is None: + return None + return pending_approvals.get(_pending_approval_key(thread_id, interrupt_id)) + + +def _pending_approval_alias_keys( + thread_id: str, + entry: PendingApprovalEntry, + *ids: str | None, +) -> set[PendingApprovalKey]: + aliases = {item for item in ids if item} + if not isinstance(entry, str): + request_id = entry.get("request_id") + interrupt_id = entry.get("interrupt_id") + if request_id: + aliases.add(request_id) + if interrupt_id: + aliases.add(interrupt_id) + return {_pending_approval_key(thread_id, alias) for alias in aliases} + + +def _consume_pending_approval_entry( + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry], + thread_id: str, + entry: PendingApprovalEntry, + *ids: str | None, +) -> None: + for alias_key in _pending_approval_alias_keys(thread_id, entry, *ids): + pending_approvals.pop(alias_key, None) + + +def _approval_arguments_match_pending(pending_arguments: str | None, response_arguments: str | None) -> bool: + return pending_arguments is None or response_arguments == pending_arguments + + +def _json_schema_value_matches(original_value: Any, edited_value: Any) -> bool: + if isinstance(original_value, bool): + return isinstance(edited_value, bool) + if isinstance(original_value, int) and not isinstance(original_value, bool): + return isinstance(edited_value, int) and not isinstance(edited_value, bool) + if isinstance(original_value, float): + return isinstance(edited_value, (int, float)) and not isinstance(edited_value, bool) + if isinstance(original_value, str): + return isinstance(edited_value, str) + if isinstance(original_value, list): + return isinstance(edited_value, list) + if isinstance(original_value, dict): + return isinstance(edited_value, dict) + return True + + +def _canonical_approval_resume_messages( + resume_payload: Any, + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None, + thread_id: str, + expected_interrupt_ids: set[str] | None = None, +) -> tuple[list[dict[str, Any]], set[str], set[str], RunErrorEvent | None]: + """Translate canonical ResumeEntry approvals into existing approval response messages.""" + expected_ids = set(expected_interrupt_ids or set()) + if pending_approvals is None: + if not expected_ids: + return [], set(), set(), None + entries, contract_error, contract_code = _resume_contract_error( + resume_payload, + expected_ids, + required_code="APPROVAL_RESUME_REQUIRED", + invalid_code="APPROVAL_RESUME_INVALID", + unknown_code="APPROVAL_RESUME_NOT_FOUND", + missing_code="APPROVAL_RESUME_MISSING_INTERRUPT", + ) + if contract_error is not None and contract_code is not None: + return [], set(), set(), RunErrorEvent(message=contract_error, code=contract_code) + cancelled_expected_ids = {str(entry["interrupt_id"]) for entry in entries if entry.get("status") == "cancelled"} + if cancelled_expected_ids: + interrupt_id = next(str(entry["interrupt_id"]) for entry in entries if entry.get("status") == "cancelled") + return ( + [], + cancelled_expected_ids, + cancelled_expected_ids, + RunErrorEvent( + message=f"Approval resume for interruptId '{interrupt_id}' was cancelled.", + code="APPROVAL_RESUME_CANCELLED", + ), + ) + interrupt_id = entries[0]["interrupt_id"] if entries else sorted(expected_ids)[0] + return ( + [], + set(), + set(), + RunErrorEvent( + message=f"No pending approval interrupt found for resume interruptId '{interrupt_id}'.", + code="APPROVAL_RESUME_NOT_FOUND", + ), + ) + + messages: list[dict[str, Any]] = [] + handled_ids: set[str] = set() + cancelled_ids: set[str] = set() + pending_interrupt_ids = _pending_approval_interrupt_ids(pending_approvals, thread_id) + contract_interrupt_ids = expected_ids | pending_interrupt_ids + if not contract_interrupt_ids: + return messages, handled_ids, cancelled_ids, None + + entries, contract_error, contract_code = _resume_contract_error( + resume_payload, + contract_interrupt_ids, + required_code="APPROVAL_RESUME_REQUIRED", + invalid_code="APPROVAL_RESUME_INVALID", + unknown_code="APPROVAL_RESUME_NOT_FOUND", + missing_code="APPROVAL_RESUME_MISSING_INTERRUPT", + ) + if contract_error is not None and contract_code is not None: + return [], handled_ids, cancelled_ids, RunErrorEvent(message=contract_error, code=contract_code) + + has_pending_for_thread = _thread_has_pending_approvals(pending_approvals, thread_id) + entries_by_interrupt_id: dict[str, PendingApprovalEntry | None] = {} + for entry in entries: + interrupt_id = cast(str, entry["interrupt_id"]) + status = entry["status"] + pending_entry = _find_pending_approval_entry(pending_approvals, thread_id, interrupt_id) + if pending_entry is None: + if status == "cancelled" and interrupt_id in expected_ids: + handled_ids.add(interrupt_id) + cancelled_ids.add(interrupt_id) + entries_by_interrupt_id[interrupt_id] = None + continue + if has_pending_for_thread or interrupt_id in expected_ids: + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"No pending approval interrupt found for resume interruptId '{interrupt_id}'.", + code="APPROVAL_RESUME_NOT_FOUND", + ), + ) + continue + + handled_ids.add(interrupt_id) + entries_by_interrupt_id[interrupt_id] = pending_entry + if status == "cancelled": + cancelled_ids.add(interrupt_id) + continue + if status != "resolved": + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"Unsupported approval resume status '{status}' for interruptId '{interrupt_id}'.", + code="APPROVAL_RESUME_INVALID", + ), + ) + + if cancelled_ids: + for interrupt_id in cancelled_ids: + pending_entry = entries_by_interrupt_id.get(interrupt_id) + if pending_entry is not None: + _consume_pending_approval_entry(pending_approvals, thread_id, pending_entry, interrupt_id) + interrupt_id = next(str(entry["interrupt_id"]) for entry in entries if entry.get("status") == "cancelled") + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"Approval resume for interruptId '{interrupt_id}' was cancelled.", + code="APPROVAL_RESUME_CANCELLED", + ), + ) + + argument_updates: list[tuple[_PendingApproval, str]] = [] + for entry in entries: + interrupt_id = cast(str, entry["interrupt_id"]) + pending_entry = entries_by_interrupt_id.get(interrupt_id) + if pending_entry is None: + continue + + payload = _parse_json_object(entry.get("payload")) + if payload is None: + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"Approval resume for interruptId '{interrupt_id}' must include an object payload.", + code="APPROVAL_RESUME_INVALID", + ), + ) + accepted = payload.get("accepted", payload.get("approved")) + if not isinstance(accepted, bool): + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"Approval resume for interruptId '{interrupt_id}' must include a boolean accepted value.", + code="APPROVAL_RESUME_INVALID", + ), + ) + + pending_arguments = _pending_approval_arguments(pending_entry) + original_arguments = _parse_json_object(pending_arguments) or {} + edited_arguments = {key: value for key, value in payload.items() if key not in {"accepted", "approved"}} + if not set(edited_arguments).issubset(set(original_arguments)): + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=f"Approval resume for interruptId '{interrupt_id}' includes unsupported edited arguments.", + code="APPROVAL_RESUME_INVALID", + ), + ) + for name, edited_value in edited_arguments.items(): + original_value = original_arguments[name] + if not _json_schema_value_matches(original_value, edited_value): + return ( + [], + handled_ids, + cancelled_ids, + RunErrorEvent( + message=( + f"Approval resume for interruptId '{interrupt_id}' has invalid type for edited " + f"argument '{name}'." + ), + code="APPROVAL_RESUME_INVALID_RESPONSE", + ), + ) + + merged_arguments = {**original_arguments, **edited_arguments} + if not isinstance(pending_entry, str): + argument_updates.append( + (pending_entry, json.dumps(make_json_safe(merged_arguments), sort_keys=True, separators=(",", ":"))) + ) + messages.append( + { + "role": "user", + "function_approvals": [ + { + "id": interrupt_id, + "call_id": interrupt_id, + "name": _pending_approval_name(pending_entry) or "", + "approved": accepted, + "arguments": merged_arguments, + } + ], + } + ) + + for pending_entry, arguments_json in argument_updates: + pending_entry["arguments"] = arguments_json + + return messages, handled_ids, cancelled_ids, None + + +def _evict_oldest_approvals(registry: dict[PendingApprovalKey, PendingApprovalEntry], max_size: int = 10_000) -> None: """Evict the oldest entries from the pending-approvals registry (LRU). Only effective when *registry* is an ``OrderedDict``; plain dicts are @@ -457,7 +809,7 @@ async def _resolve_approval_responses( tools: list[Any], agent: SupportsAgentRun, run_kwargs: dict[str, Any], - pending_approvals: dict[str, PendingApprovalEntry] | None = None, + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None = None, thread_id: str = "", ) -> list[Content]: """Execute approved function calls and replace approval content with results. @@ -471,7 +823,7 @@ async def _resolve_approval_responses( agent: The agent instance (to get client and config) run_kwargs: Kwargs for tool execution pending_approvals: Server-side registry of pending approval requests. - Keys are ``{thread_id}:{request_id}``, values are function names. + Keys are ``(thread_id, request_id)``, values are function names. When provided, every approval response is validated against this registry to prevent bypass, function name spoofing, and replay. thread_id: The conversation thread ID used to scope registry keys. @@ -500,7 +852,7 @@ async def _resolve_approval_responses( for resp in approved_responses + rejected_responses: resp_id = resp.id or "" resp_name = resp.function_call.name if resp.function_call else None - registry_key = f"{thread_id}:{resp_id}" + registry_key = _pending_approval_key(thread_id, resp_id) if registry_key not in pending_approvals: logger.warning( @@ -524,7 +876,7 @@ async def _resolve_approval_responses( pending_arguments = _pending_approval_arguments(pending_entry) response_arguments = canonical_function_arguments(resp.function_call) - if pending_arguments is not None and response_arguments != pending_arguments: + if not _approval_arguments_match_pending(pending_arguments, response_arguments): logger.warning( "Rejected approval response id=%s: function arguments mismatch", resp_id, @@ -533,7 +885,13 @@ async def _resolve_approval_responses( continue # Valid — consume entry to prevent replay - del pending_approvals[registry_key] + _consume_pending_approval_entry( + pending_approvals, + thread_id, + pending_entry, + resp_id, + resp.function_call.call_id if resp.function_call else None, + ) if resp.approved: validated.append(resp) else: @@ -834,7 +1192,7 @@ async def run_agent_stream( input_data: dict[str, Any], agent: SupportsAgentRun, config: AgentConfig, - pending_approvals: dict[str, PendingApprovalEntry] | None = None, + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] | None = None, ) -> AsyncGenerator[BaseEvent]: """Run agent and yield AG-UI events. @@ -846,7 +1204,7 @@ async def run_agent_stream( agent: The Agent Framework agent to run config: Agent configuration pending_approvals: Optional server-side registry of pending approval - requests. Keys are ``{thread_id}:{request_id}``, values are + requests. Keys are ``(thread_id, request_id)``, values are function names. When provided, approval responses are validated against this registry to prevent bypass, spoofing, and replay. @@ -876,9 +1234,16 @@ async def run_agent_stream( return stored_snapshot: AGUIThreadSnapshot | None = None + stored_pending_approval_interrupt_ids: set[str] = set() + seeded_resume_from_snapshot = False if config.snapshot_store is not None and snapshot_scope is not None: stored_snapshot = await config.snapshot_store.get(scope=snapshot_scope, thread_id=thread_id) - if stored_snapshot is not None and resume_payload is None: + if stored_snapshot is not None: + stored_pending_approval_interrupt_ids = _stored_pending_approval_interrupt_ids(stored_snapshot.interrupt) + if stored_snapshot is not None and resume_payload is not None and stored_pending_approval_interrupt_ids: + raw_messages = [copy.deepcopy(message) for message in stored_snapshot.messages] + raw_messages + seeded_resume_from_snapshot = True + elif stored_snapshot is not None: raw_messages = _reconstruct_messages_from_thread_snapshot( stored_messages=stored_snapshot.messages, incoming_messages=raw_messages, @@ -921,9 +1286,35 @@ async def run_agent_stream( current_state=flow.current_state, ) - resume_messages = _resume_to_tool_messages(resume_payload) + approval_resume_messages, handled_resume_ids, cancelled_resume_ids, resume_error = ( + _canonical_approval_resume_messages( + resume_payload, + pending_approvals, + thread_id, + expected_interrupt_ids=stored_pending_approval_interrupt_ids or None, + ) + ) + if resume_error is not None: + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + if ( + getattr(resume_error, "code", None) == "APPROVAL_RESUME_CANCELLED" + and config.snapshot_store is not None + and snapshot_scope is not None + ): + await _clear_thread_snapshot_interrupt( + snapshot_store=config.snapshot_store, + scope=snapshot_scope, + thread_id=thread_id, + interrupt_ids=cancelled_resume_ids or None, + ) + yield resume_error + return + resume_messages = _resume_to_tool_messages(resume_payload, exclude_interrupt_ids=handled_resume_ids) if available_interrupts: logger.debug("Received available interrupts metadata: %s", available_interrupts) + if approval_resume_messages: + logger.info(f"Appending {len(approval_resume_messages)} synthesized approval resume message(s).") + raw_messages.extend(approval_resume_messages) if resume_messages: logger.info(f"Appending {len(resume_messages)} synthesized resume message(s) to AG-UI input.") raw_messages.extend(resume_messages) @@ -1015,9 +1406,9 @@ async def run_agent_stream( # Persist the completed confirmation turn with interrupt=None so hydration # does not replay the stale pending interrupt after the user responded. persisted_messages = snapshot_messages + _text_events_to_snapshot_messages(confirmation_events) - if resume_payload is not None and stored_snapshot is not None: - # Resume requests carry only the synthesized interrupt response, so prepend - # the stored thread history to avoid persisting a truncated thread. + if resume_payload is not None and stored_snapshot is not None and not seeded_resume_from_snapshot: + # Generic resume requests carry only the synthesized response, so prepend + # stored history unless this run already seeded raw messages from it. persisted_messages = [copy.deepcopy(message) for message in stored_snapshot.messages] + persisted_messages await _save_thread_snapshot( config=config, @@ -1092,10 +1483,16 @@ async def run_agent_stream( # Register pending approval requests so we can validate responses later if content_type == "function_approval_request" and pending_approvals is not None: if content.id and content.function_call and content.function_call.name: - pending_approvals[f"{thread_id}:{content.id}"] = _make_pending_approval_entry( + canonical_interrupt_id = content.function_call.call_id or content.id + pending_entry = _make_pending_approval_entry( content.function_call.name, canonical_function_arguments(content.function_call), + request_id=str(content.id), + interrupt_id=str(canonical_interrupt_id), ) + pending_approvals[_pending_approval_key(thread_id, str(content.id))] = pending_entry + if canonical_interrupt_id: + pending_approvals[_pending_approval_key(thread_id, str(canonical_interrupt_id))] = pending_entry # Evict oldest entries if the registry exceeds a safe bound (LRU) _evict_oldest_approvals(pending_approvals, max_size=10_000) else: @@ -1250,17 +1647,17 @@ async def run_agent_stream( flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event flow.waiting_for_approval = True flow.interrupts.append( - { - "id": str(confirm_id), - "value": { - "type": "function_approval_request", - "function_call": { - "call_id": tool_call_id, - "name": tool_name, - "arguments": function_arguments, - }, - }, - } + _approval_interrupt_for_function_call( + interrupt_id=str(confirm_id), + function_call=Content.from_function_call( + call_id=tool_call_id, + name=tool_name, + arguments=function_arguments, + ), + message=f"Approve the proposed changes from {tool_name}?", + response_schema=_approval_steps_response_schema(), + metadata={"confirmation_tool_call_id": confirm_id}, + ) ) # Close any open reasoning block @@ -1297,9 +1694,9 @@ async def run_agent_stream( # Always emit RunFinished - confirm_changes tool call is complete (Start -> Args -> End) # The UI will show confirmation dialog and send a new request when user responds persisted_messages = latest_messages_snapshot - if resume_payload is not None and stored_snapshot is not None: - # Resume requests carry only the synthesized interrupt response, so prepend - # the stored thread history to avoid persisting a truncated thread. + if resume_payload is not None and stored_snapshot is not None and not seeded_resume_from_snapshot: + # Generic resume requests carry only the synthesized response, so prepend + # stored history unless this run already seeded raw messages from it. persisted_messages = [copy.deepcopy(message) for message in stored_snapshot.messages] + persisted_messages await _save_thread_snapshot( config=config, diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index ae81162b6d5..d271172c86d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -27,7 +27,7 @@ from agent_framework.observability import ChatTelemetryLayer from ._event_converters import AGUIEventConverter -from ._http_service import AGUIHttpService +from ._http_service import AGUIHttpService, _serialize_available_interrupts, _serialize_resume from ._message_adapters import agent_framework_messages_to_agui from ._utils import convert_tools_to_agui_format @@ -430,17 +430,16 @@ async def _streaming_impl( converter = AGUIEventConverter() + available_interrupts = options.get("available_interrupts", options.get("availableInterrupts")) + async for event in self._http_service.post_run( thread_id=thread_id, run_id=run_id, messages=agui_messages, state=state, tools=agui_tools, - available_interrupts=cast( - list[dict[str, Any]] | None, - options.get("available_interrupts") or options.get("availableInterrupts"), - ), - resume=cast(dict[str, Any] | None, options.get("resume")), + available_interrupts=_serialize_available_interrupts(cast(Sequence[Any] | None, available_interrupts)), + resume=_serialize_resume(options.get("resume")), ): logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}") update = converter.convert_event(event) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py b/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py index ac024e326fa..3510661a355 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_event_converters.py @@ -4,6 +4,7 @@ from __future__ import annotations +import logging from typing import Any from agent_framework import ( @@ -11,6 +12,8 @@ Content, ) +logger = logging.getLogger(__name__) + class AGUIEventConverter: """Converter for AG-UI events to Agent Framework types. @@ -185,6 +188,18 @@ def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate: } if "interrupt" in event: additional_properties["interrupt"] = event.get("interrupt") + if "outcome" in event: + outcome = event.get("outcome") + additional_properties["outcome"] = outcome + if not isinstance(outcome, dict): + logger.warning( + "RUN_FINISHED outcome should be an object; got %s. Preserving raw outcome.", + type(outcome).__name__, + ) + elif outcome.get("type") == "interrupt": + interrupts = outcome.get("interrupts") + if isinstance(interrupts, list): + additional_properties["interrupts"] = interrupts if "result" in event: additional_properties["result"] = event.get("result") diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py index 7ec2d429ff7..f2209789ce9 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py @@ -6,14 +6,107 @@ import json import logging -from collections.abc import AsyncIterable -from typing import Any +from collections.abc import AsyncIterable, Mapping, Sequence +from typing import Any, cast import httpx +from ag_ui.core import Interrupt, ResumeEntry logger = logging.getLogger(__name__) +def _json_safe_protocol_value(value: Any) -> Any: + """Convert protocol values to JSON-compatible data using AG-UI aliases.""" + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _json_safe_protocol_value(model_dump(by_alias=True, exclude_none=True)) + if isinstance(value, Mapping): + return {key: _json_safe_protocol_value(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_json_safe_protocol_value(item) for item in value] + return value + + +def _serialize_available_interrupts(available_interrupts: Sequence[Any] | None) -> list[dict[str, Any]] | None: + """Serialize typed or compatible interrupt inputs to canonical AG-UI JSON.""" + if available_interrupts is None: + return None + serialized: list[dict[str, Any]] = [] + for interrupt in available_interrupts: + if isinstance(interrupt, Mapping) and "reason" not in interrupt: + interrupt = dict(interrupt) + interrupt_type = interrupt.pop("type", None) + if interrupt_type == "request_info" or interrupt_type is None: + interrupt["reason"] = "input_required" + elif isinstance(interrupt_type, str): + interrupt["reason"] = interrupt_type + serialized.append( + cast(dict[str, Any], Interrupt.model_validate(interrupt).model_dump(by_alias=True, exclude_none=True)) + ) + return serialized + + +def _serialize_resume_entry(entry: Any) -> dict[str, Any]: + """Serialize one typed or legacy resume entry to canonical AG-UI JSON.""" + model_dump = getattr(entry, "model_dump", None) + if callable(model_dump): + entry = model_dump(by_alias=True, exclude_none=True) + + if not isinstance(entry, Mapping): + raise ValueError("Each resume entry must be an object.") + + entry_dict = cast(Mapping[str, Any], entry) + interrupt_id = ( + entry_dict.get("interruptId") + or entry_dict.get("interrupt_id") + or entry_dict.get("id") + or entry_dict.get("toolCallId") + ) + if not interrupt_id: + raise ValueError("Each resume entry must include interruptId.") + + status = entry_dict.get("status") or "resolved" + payload = ( + entry_dict.get("payload") + if "payload" in entry_dict + else entry_dict.get("value") + if "value" in entry_dict + else entry_dict.get("response") + if "response" in entry_dict + else { + key: value + for key, value in entry_dict.items() + if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"} + } + ) + + serialized: dict[str, Any] = {"interruptId": str(interrupt_id), "status": str(status)} + if status != "cancelled" or payload: + serialized["payload"] = _json_safe_protocol_value(payload) + return cast(dict[str, Any], ResumeEntry.model_validate(serialized).model_dump(by_alias=True, exclude_none=True)) + + +def _serialize_resume(resume: Any) -> Any: # noqa: ANN401 + """Serialize typed or compatible resume inputs to canonical AG-UI JSON.""" + if resume is None: + return None + if isinstance(resume, Sequence) and not isinstance(resume, (str, bytes, bytearray)): + return [_serialize_resume_entry(entry) for entry in resume] + if isinstance(resume, Mapping): + resume_dict = cast(Mapping[str, Any], resume) + if isinstance(resume_dict.get("interrupts"), Sequence) and not isinstance( + resume_dict.get("interrupts"), (str, bytes, bytearray) + ): + return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupts"])] + if isinstance(resume_dict.get("interrupt"), Sequence) and not isinstance( + resume_dict.get("interrupt"), (str, bytes, bytearray) + ): + return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupt"])] + if any(key in resume_dict for key in ("interruptId", "interrupt_id", "id", "toolCallId")): + return [_serialize_resume_entry(resume_dict)] + return _json_safe_protocol_value(resume) + + class AGUIHttpService: """HTTP service for AG-UI protocol communication. @@ -66,8 +159,8 @@ async def post_run( messages: list[dict[str, Any]], state: dict[str, Any] | None = None, tools: list[dict[str, Any]] | None = None, - available_interrupts: list[dict[str, Any]] | None = None, - resume: dict[str, Any] | None = None, + available_interrupts: Sequence[Any] | None = None, + resume: Any = None, ) -> AsyncIterable[dict[str, Any]]: """Post a run request and stream AG-UI events. @@ -113,11 +206,13 @@ async def post_run( if tools is not None: request_data["tools"] = tools - if available_interrupts is not None: - request_data["availableInterrupts"] = available_interrupts + serialized_available_interrupts = _serialize_available_interrupts(available_interrupts) + if serialized_available_interrupts is not None: + request_data["availableInterrupts"] = serialized_available_interrupts - if resume is not None: - request_data["resume"] = resume + serialized_resume = _serialize_resume(resume) + if serialized_resume is not None: + request_data["resume"] = serialized_resume logger.debug( f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, " diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index a679c069cd7..30a08829eed 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -14,6 +14,7 @@ from ag_ui.core import ( BaseEvent, CustomEvent, + Interrupt, ReasoningEncryptedValueEvent, ReasoningEndEvent, ReasoningMessageContentEvent, @@ -21,6 +22,7 @@ ReasoningMessageStartEvent, ReasoningStartEvent, RunFinishedEvent, + RunFinishedInterruptOutcome, StateSnapshotEvent, TextMessageContentEvent, TextMessageEndEvent, @@ -69,21 +71,39 @@ def _normalize_resume_interrupts(resume_payload: Any) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for item in candidates: + model_dump = getattr(item, "model_dump", None) + if callable(model_dump): + item = model_dump(by_alias=True, exclude_none=True) if not isinstance(item, dict): continue item_dict = cast(dict[str, Any], item) - interrupt_id = item_dict.get("id") or item_dict.get("interruptId") or item_dict.get("toolCallId") + interrupt_id = ( + item_dict.get("id") + or item_dict.get("interruptId") + or item_dict.get("interrupt_id") + or item_dict.get("toolCallId") + ) if not interrupt_id: continue - if "value" in item_dict: + if "payload" in item_dict: + value = item_dict.get("payload") + elif "value" in item_dict: value = item_dict.get("value") elif "response" in item_dict: value = item_dict.get("response") else: - value = {k: v for k, v in item_dict.items() if k not in {"id", "interruptId", "toolCallId", "type"}} + value = { + k: v + for k, v in item_dict.items() + if k not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"} + } - normalized.append({"id": str(interrupt_id), "value": value}) + normalized_entry = {"id": str(interrupt_id), "value": value} + status = item_dict.get("status") + if isinstance(status, str) and status: + normalized_entry["status"] = status + normalized.append(normalized_entry) return normalized @@ -108,12 +128,324 @@ def _extract_resume_payload(input_data: dict[str, Any]) -> Any: return forwarded_props_dict.get("resume") +def _strict_resume_entries(resume_payload: Any) -> tuple[list[dict[str, Any]], str | None]: + """Parse resume entries for pending interrupt contract validation.""" + if isinstance(resume_payload, list): + candidates = resume_payload + elif isinstance(resume_payload, dict): + resume_dict = cast(dict[str, Any], resume_payload) + if isinstance(resume_dict.get("interrupts"), list): + candidates = cast(list[Any], resume_dict["interrupts"]) + elif isinstance(resume_dict.get("interrupt"), list): + candidates = cast(list[Any], resume_dict["interrupt"]) + else: + candidates = [resume_dict] + else: + return [], "Resume payload must be a list of entries or an object containing interrupt entries." + + if not candidates: + return [], "Resume payload must include at least one interrupt entry." + + entries: list[dict[str, Any]] = [] + for item in candidates: + model_dump = getattr(item, "model_dump", None) + if callable(model_dump): + item = model_dump(by_alias=True, exclude_none=True) + if not isinstance(item, dict): + return [], "Each resume entry must be an object." + + item_dict = cast(dict[str, Any], item) + interrupt_id = ( + item_dict.get("interruptId") + or item_dict.get("interrupt_id") + or item_dict.get("id") + or item_dict.get("toolCallId") + ) + if not interrupt_id: + return [], "Each resume entry must include interruptId." + + status = item_dict.get("status") or "resolved" + if status not in {"resolved", "cancelled"}: + return [], f"Unsupported resume status '{status}'." + + if "payload" in item_dict: + payload = item_dict.get("payload") + elif "value" in item_dict: + payload = item_dict.get("value") + elif "response" in item_dict: + payload = item_dict.get("response") + else: + payload = { + key: value + for key, value in item_dict.items() + if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"} + } + + entries.append({"interrupt_id": str(interrupt_id), "status": str(status), "payload": payload}) + + return entries, None + + +def _resume_contract_error( + resume_payload: Any, + pending_interrupt_ids: set[str], + *, + required_code: str, + invalid_code: str, + unknown_code: str, + missing_code: str, +) -> tuple[list[dict[str, Any]], str | None, str | None]: + """Validate that resume entries address every pending interrupt exactly once.""" + if not pending_interrupt_ids: + entries, error = _strict_resume_entries(resume_payload) + return entries, error, invalid_code if error else None + + if resume_payload is None: + return [], "Pending interrupts require a resume entry for every interruptId.", required_code + + entries, error = _strict_resume_entries(resume_payload) + if error is not None: + return [], error, invalid_code + + seen: set[str] = set() + for entry in entries: + interrupt_id = str(entry["interrupt_id"]) + if interrupt_id in seen: + return [], f"Resume includes duplicate interruptId '{interrupt_id}'.", invalid_code + seen.add(interrupt_id) + + unknown = seen - pending_interrupt_ids + if unknown: + interrupt_id = sorted(unknown)[0] + return [], f"No pending interrupt found for resume interruptId '{interrupt_id}'.", unknown_code + + missing = pending_interrupt_ids - seen + if missing: + interrupt_id = sorted(missing)[0] + return [], f"Resume omitted pending interruptId '{interrupt_id}'.", missing_code + + return entries, None, None + + +def _canonical_interrupt_message(value: Any) -> str | None: + """Extract a human-readable message from legacy interruption metadata.""" + if isinstance(value, str): + return value + if not isinstance(value, Mapping): + return None + value_mapping = cast(Mapping[str, Any], value) + for key in ("message", "prompt", "question", "data"): + message = value_mapping.get(key) + if isinstance(message, str) and message: + return message + return None + + +def _canonical_interrupt_tool_call_id(interrupt: Mapping[str, Any], value: Any) -> str | None: + """Extract a tool call id from canonical fields or legacy interruption metadata.""" + direct_tool_call_id = interrupt.get("toolCallId") or interrupt.get("tool_call_id") + if direct_tool_call_id: + return str(direct_tool_call_id) + + if not isinstance(value, Mapping): + return None + value_mapping = cast(Mapping[str, Any], value) + function_call = value_mapping.get("function_call") or value_mapping.get("functionCall") + if not isinstance(function_call, Mapping): + return None + function_call_mapping = cast(Mapping[str, Any], function_call) + tool_call_id = function_call_mapping.get("call_id") or function_call_mapping.get("callId") + return str(tool_call_id) if tool_call_id else None + + +def _canonical_interrupt_reason(interrupt: Mapping[str, Any], value: Any) -> str: + """Infer the canonical AG-UI interrupt reason from existing interruption metadata.""" + reason = interrupt.get("reason") + if isinstance(reason, str) and reason: + return reason + if isinstance(value, Mapping): + value_mapping = cast(Mapping[str, Any], value) + if value_mapping.get("type") == "function_approval_request" or value_mapping.get("function_call"): + return "tool_call" + return "input_required" + + +def _canonical_interrupt_metadata(interrupt: Mapping[str, Any], value: Any) -> dict[str, Any] | None: + """Move framework-specific legacy interruption details under metadata.""" + raw_metadata = interrupt.get("metadata") + metadata = dict(cast(Mapping[str, Any], raw_metadata)) if isinstance(raw_metadata, Mapping) else {} + if "value" in interrupt: + raw_agent_framework = metadata.get("agent_framework") + agent_framework = ( + dict(cast(Mapping[str, Any], raw_agent_framework)) if isinstance(raw_agent_framework, Mapping) else {} + ) + agent_framework.setdefault("value", make_json_safe(value)) + metadata["agent_framework"] = agent_framework + return metadata or None + + +def _canonical_interrupt(interrupt: Mapping[str, Any]) -> Interrupt | None: + """Convert a legacy or protocol-compatible interrupt mapping into an AG-UI Interrupt.""" + interrupt_id = interrupt.get("id") or interrupt.get("interruptId") + if not interrupt_id: + return None + + value = interrupt.get("value") + interrupt_data: dict[str, Any] = { + "id": str(interrupt_id), + "reason": _canonical_interrupt_reason(interrupt, value), + } + + message = interrupt.get("message") or _canonical_interrupt_message(value) + if isinstance(message, str) and message: + interrupt_data["message"] = message + + tool_call_id = _canonical_interrupt_tool_call_id(interrupt, value) + if tool_call_id: + interrupt_data["toolCallId"] = tool_call_id + + response_schema = interrupt.get("responseSchema") or interrupt.get("response_schema") + if isinstance(response_schema, Mapping): + interrupt_data["responseSchema"] = dict(cast(Mapping[str, Any], response_schema)) + + expires_at = interrupt.get("expiresAt") or interrupt.get("expires_at") + if isinstance(expires_at, str) and expires_at: + interrupt_data["expiresAt"] = expires_at + + metadata = _canonical_interrupt_metadata(interrupt, value) + if metadata is not None: + interrupt_data["metadata"] = metadata + + return Interrupt.model_validate(interrupt_data) + + +def _json_schema_for_value(value: Any) -> dict[str, Any]: + """Infer a lightweight JSON schema for an already-serialized value.""" + if isinstance(value, bool): + return {"type": "boolean"} + if isinstance(value, int): + return {"type": "integer"} + if isinstance(value, float): + return {"type": "number"} + if isinstance(value, str): + return {"type": "string"} + if isinstance(value, list): + return {"type": "array"} + if isinstance(value, Mapping): + return {"type": "object", "additionalProperties": True} + return {} + + +def _approval_response_schema(arguments: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Build the response schema generic AG-UI clients use to render approval input.""" + properties: dict[str, Any] = { + "accepted": { + "type": "boolean", + "description": "Whether the requested tool call is approved.", + } + } + if arguments: + for name, value in arguments.items(): + argument_schema = _json_schema_for_value(value) + argument_schema["description"] = f"Optional edited value for the '{name}' tool argument." + properties[str(name)] = argument_schema + + return { + "type": "object", + "properties": properties, + "required": ["accepted"], + "additionalProperties": False, + } + + +def _approval_steps_response_schema() -> dict[str, Any]: + """Build the response schema for step-based confirmation prompts.""" + return { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "description": "Whether the proposed tool changes are approved.", + }, + "steps": { + "type": "array", + "description": "The approved and rejected steps.", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "status": {"type": "string", "enum": ["enabled", "disabled"]}, + }, + "required": ["description", "status"], + "additionalProperties": True, + }, + }, + }, + "required": ["accepted"], + "additionalProperties": False, + } + + +def _function_call_interrupt_metadata(function_call: Content) -> dict[str, Any]: + """Build Agent Framework metadata for a tool-bound approval interrupt.""" + parsed_arguments = make_json_safe(function_call.parse_arguments()) + return { + "type": "function_approval_request", + "function_call": { + "call_id": function_call.call_id, + "name": function_call.name, + "arguments": parsed_arguments, + }, + } + + +def _approval_interrupt_for_function_call( + *, + interrupt_id: str, + function_call: Content, + message: str | None = None, + response_schema: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + tool_call_id: str | None = None, +) -> dict[str, Any]: + """Build a canonical AG-UI interrupt descriptor for a tool approval pause.""" + function_metadata = _function_call_interrupt_metadata(function_call) + parsed_arguments = function_metadata["function_call"]["arguments"] + argument_mapping = cast(Mapping[str, Any], parsed_arguments) if isinstance(parsed_arguments, Mapping) else {} + interrupt_message = message or f"Approve running {function_call.name or 'this tool'}?" + agent_framework_metadata = {**function_metadata, "value": function_metadata} + if metadata: + agent_framework_metadata.update(dict(metadata)) + return { + "id": interrupt_id, + "reason": "tool_call", + "message": interrupt_message, + "toolCallId": tool_call_id or function_call.call_id, + "responseSchema": dict(response_schema or _approval_response_schema(argument_mapping)), + "metadata": {"agent_framework": agent_framework_metadata}, + } + + def _build_run_finished_event( run_id: str, thread_id: str, interrupts: list[dict[str, Any]] | None = None ) -> RunFinishedEvent: """Create a RUN_FINISHED event, optionally carrying interrupt metadata.""" if interrupts: - return RunFinishedEvent(run_id=run_id, thread_id=thread_id, interrupt=interrupts) # type: ignore[call-arg] + canonical_interrupts = [ + canonical for interrupt in interrupts if (canonical := _canonical_interrupt(interrupt)) is not None + ] + if canonical_interrupts: + return RunFinishedEvent( + run_id=run_id, + thread_id=thread_id, + outcome=RunFinishedInterruptOutcome(interrupts=canonical_interrupts), + ) + logger.warning( + "run %s: %d interrupt(s) present but none carried an id/interruptId; " + "emitting RUN_FINISHED with no interrupt outcome", + run_id, + len(interrupts), + ) return RunFinishedEvent(run_id=run_id, thread_id=thread_id) @@ -444,17 +776,10 @@ def _emit_approval_request( interrupt_id = func_call_id or content.id if interrupt_id: flow.interrupts.append( - { - "id": str(interrupt_id), - "value": { - "type": "function_approval_request", - "function_call": { - "call_id": func_call_id, - "name": func_name, - "arguments": make_json_safe(func_call.parse_arguments()), - }, - }, - } + _approval_interrupt_for_function_call( + interrupt_id=str(interrupt_id), + function_call=func_call, + ) ) if require_confirmation: @@ -781,6 +1106,9 @@ def _known_tool_call_ids( interrupt_id = interrupt.get("id") if interrupt_id: known_ids.add(str(interrupt_id)) + canonical_tool_call_id = interrupt.get("toolCallId") or interrupt.get("tool_call_id") + if canonical_tool_call_id: + known_ids.add(str(canonical_tool_call_id)) return known_ids diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_snapshots.py b/python/packages/ag-ui/agent_framework_ag_ui/_snapshots.py index b619f99c810..4993b1aa2ae 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_snapshots.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_snapshots.py @@ -5,6 +5,7 @@ from __future__ import annotations import copy +import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, runtime_checkable @@ -26,6 +27,7 @@ DEFAULT_MAX_THREAD_SNAPSHOTS = 1_000 _SNAPSHOT_SCOPE_INPUT_KEY = "__ag_ui_snapshot_scope" _DEFAULT_STATE_INPUT_KEY = "__ag_ui_default_state" +logger = logging.getLogger(__name__) @dataclass(slots=True) @@ -40,7 +42,7 @@ class AGUIThreadSnapshot: Attributes: messages: Replayable AG-UI message snapshots. state: Optional AG-UI Shared State snapshot. - interrupt: Optional interruption state from ``RUN_FINISHED.interrupt``. + interrupt: Optional interruption state from ``RUN_FINISHED.outcome.interrupts``. """ messages: list[dict[str, Any]] = field(default_factory=list) @@ -200,3 +202,33 @@ def _normalize_key_part(value: str, name: str) -> str: def _evict_oldest(self) -> None: while len(self._snapshots) > self._max_snapshots: del self._snapshots[next(iter(self._snapshots))] + + +async def _clear_thread_snapshot_interrupt( + *, + snapshot_store: AGUIThreadSnapshotStore, + scope: SnapshotScope, + thread_id: AGUIThreadID, + interrupt_ids: set[str] | None = None, +) -> None: + """Clear completed interruption state from the latest replayable thread snapshot.""" + try: + snapshot = await snapshot_store.get(scope=scope, thread_id=thread_id) + if snapshot is None or snapshot.interrupt is None: + return + if interrupt_ids is None: + snapshot.interrupt = None + else: + remaining_interrupts = [ + interrupt + for interrupt in snapshot.interrupt + if str(interrupt.get("id") or interrupt.get("interruptId")) not in interrupt_ids + ] + snapshot.interrupt = remaining_interrupts or None + await snapshot_store.save(scope=scope, thread_id=thread_id, snapshot=snapshot) + except Exception: + logger.exception( + "Failed to clear AG-UI Thread Snapshot interrupt for scope=%s thread_id=%s; keeping previous snapshot.", + scope, + thread_id, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py index d105c3cb945..cc6dddf19ef 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_types.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -2,11 +2,14 @@ """Type definitions for AG-UI integration.""" +from __future__ import annotations + import sys -from typing import Any, Generic +from typing import Annotated, Any, Generic +from ag_ui.core import Interrupt, ResumeEntry from agent_framework import ChatOptions -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, BaseModel, BeforeValidator, Field if sys.version_info >= (3, 13): from typing import TypeVar # pragma: no cover @@ -22,6 +25,50 @@ ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +def _coerce_legacy_resume_entry(value: Any) -> Any: # noqa: ANN401 + if not isinstance(value, dict): + return value + + interrupt_id = value.get("interruptId") or value.get("interrupt_id") or value.get("id") or value.get("toolCallId") + if not interrupt_id: + return value + + if "payload" in value: + payload = value.get("payload") + elif "value" in value: + payload = value.get("value") + elif "response" in value: + payload = value.get("response") + else: + payload = { + key: item + for key, item in value.items() + if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"} + } + + entry: dict[str, Any] = {"interruptId": str(interrupt_id), "status": value.get("status", "resolved")} + if payload is not None: + entry["payload"] = payload + return entry + + +def _coerce_legacy_resume(value: Any) -> Any: # noqa: ANN401 + if value is None: + return value + if isinstance(value, dict): + if "interrupts" in value: + value = value["interrupts"] + elif "interrupt" in value: + value = value["interrupt"] + elif any(key in value for key in ("interruptId", "interrupt_id", "id", "toolCallId")): + value = [value] + else: + return value + if not isinstance(value, list): + return value + return [_coerce_legacy_resume_entry(entry) for entry in value] + + class PredictStateConfig(TypedDict): """Configuration for predictive state updates.""" @@ -83,14 +130,14 @@ class AGUIRequest(BaseModel): validation_alias=AliasChoices("parent_run_id", "parentRunId"), description="ID of the run that spawned this run", ) - available_interrupts: list[dict[str, Any]] | None = Field( + available_interrupts: list[Interrupt] | None = Field( None, validation_alias=AliasChoices("availableInterrupts", "available_interrupts"), - description="List of interrupts that can be resumed by the server", + description="Canonical AG-UI interrupts that can be resumed by the server", ) - resume: dict[str, Any] | None = Field( + resume: Annotated[list[ResumeEntry], BeforeValidator(_coerce_legacy_resume)] | None = Field( None, - description="Resume payload containing interrupt responses", + description="Resume payload for continuing interrupted runs", ) @@ -153,11 +200,11 @@ class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], tota context: dict[str, Any] """Shared context/state to send to the server.""" - available_interrupts: list[dict[str, Any]] - """Interrupt descriptors available for resumption.""" + available_interrupts: list[Interrupt] + """Canonical AG-UI interrupt descriptors available for resumption.""" - resume: dict[str, Any] - """Interrupt resume payload to continue a paused run.""" + resume: list[ResumeEntry] + """Canonical AG-UI resume entries to continue a paused run.""" # ChatOptions fields not applicable for AG-UI store: None # type: ignore[misc] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py index aa583856a65..d558a65a30e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py @@ -30,6 +30,7 @@ from ._run_common import ( _build_run_finished_event, _extract_resume_payload, + _normalize_resume_interrupts, _reconstruct_messages_from_thread_snapshot, ) from ._snapshots import ( @@ -37,6 +38,7 @@ _SNAPSHOT_SCOPE_INPUT_KEY, AGUIThreadSnapshot, AGUIThreadSnapshotStore, + _clear_thread_snapshot_interrupt, ) from ._utils import generate_event_id, make_json_safe from ._workflow_run import run_workflow_stream @@ -46,6 +48,15 @@ WorkflowFactory = Callable[[str], Workflow] +def _cancelled_resume_interrupt_ids(resume_payload: Any) -> set[str]: + """Return cancelled interrupt ids from a resume payload.""" + return { + str(interrupt["id"]) + for interrupt in _normalize_resume_interrupts(resume_payload) + if interrupt.get("status") == "cancelled" + } + + def _event_messages_to_snapshot_dicts(messages: list[Any]) -> list[dict[str, Any]]: """Convert AG-UI message event models to plain snapshot dictionaries.""" safe_messages = make_json_safe(messages) @@ -79,7 +90,12 @@ def observe(self, event: BaseEvent) -> None: return if isinstance(event, RunFinishedEvent): - interrupt = make_json_safe(getattr(event, "interrupt", None)) + outcome = getattr(event, "outcome", None) + interrupt = ( + make_json_safe(getattr(outcome, "interrupts", None)) + if getattr(outcome, "type", None) == "interrupt" + else None + ) if isinstance(interrupt, list): self.interrupt = [cast(dict[str, Any], item) for item in interrupt if isinstance(item, dict)] return @@ -352,6 +368,17 @@ async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]: snapshot_builder.observe(event) if isinstance(event, RunErrorEvent): run_error_emitted = True + if ( + getattr(event, "code", None) == "WORKFLOW_RESUME_CANCELLED" + and snapshot_store is not None + and snapshot_scope is not None + ): + await _clear_thread_snapshot_interrupt( + snapshot_store=snapshot_store, + scope=snapshot_scope, + thread_id=thread_id, + interrupt_ids=_cancelled_resume_interrupt_ids(resume_payload), + ) yield event if ( diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index 44d571aef8d..325b56ce730 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -34,6 +34,7 @@ _emit_content, _extract_resume_payload, _normalize_resume_interrupts, + _resume_contract_error, ) from ._utils import canonical_function_arguments, generate_event_id, make_json_safe @@ -63,6 +64,63 @@ _INTERRUPT_CARD_EVENT_NAME = "WorkflowInterruptEvent" +def _json_schema_for_response_type(response_type: Any) -> dict[str, Any] | None: + """Build a lightweight response schema for a workflow request_info response type.""" + if response_type is None: + return None + + target_type = get_origin(response_type) or response_type + if target_type is Any: + return {"description": "Resolved workflow response payload."} + if target_type is dict: + return {"type": "object", "additionalProperties": True} + if target_type is list: + item_types = get_args(response_type) + schema: dict[str, Any] = {"type": "array"} + if item_types: + item_schema = _json_schema_for_response_type(item_types[0]) + if item_schema is not None: + schema["items"] = item_schema + return schema + if target_type is str: + return {"type": "string"} + if target_type is bool: + return {"type": "boolean"} + if target_type is int: + return {"type": "integer"} + if target_type is float: + return {"type": "number"} + if target_type in {Message, Content}: + return {"type": "object", "additionalProperties": True} + return {"description": f"Resolved workflow response payload for {getattr(target_type, '__name__', target_type)}."} + + +def _workflow_interrupt_value(request_data: Any) -> Any: + """Normalize workflow request data into legacy metadata value form.""" + safe_request_data = make_json_safe(request_data) + if isinstance(safe_request_data, dict): + return safe_request_data + return {"data": safe_request_data} + + +def _workflow_interrupt_metadata(request_payload: dict[str, Any], value: Any) -> dict[str, Any]: + """Build Agent Framework metadata for workflow request_info interrupts.""" + agent_framework_metadata = { + key: make_json_safe(value) + for key, value in { + "type": "workflow_request_info", + "request_id": request_payload.get("request_id"), + "source_executor_id": request_payload.get("source_executor_id"), + "request_type": request_payload.get("request_type"), + "response_type": request_payload.get("response_type"), + "data": request_payload.get("data"), + "value": value, + }.items() + if value is not None + } + return {"agent_framework": agent_framework_metadata} + + async def _pending_request_events(workflow: Workflow) -> dict[str, Any]: """Best-effort retrieval of pending request_info events from workflow context.""" runner_context = getattr(workflow, "_runner_context", None) @@ -86,15 +144,21 @@ async def _pending_request_events(workflow: Workflow) -> dict[str, Any]: def _interrupt_entry_for_request_event(request_event: Any) -> dict[str, Any] | None: """Build AG-UI interrupt payload from a workflow request_info event.""" - request_id = getattr(request_event, "request_id", None) - if request_id is None: + request_payload = _request_payload_from_request_event(request_event) + if request_payload is None: return None - request_data = make_json_safe(getattr(request_event, "data", None)) - if isinstance(request_data, dict): - value: Any = request_data - else: - value = {"data": request_data} - return {"id": str(request_id), "value": value} + + value = _workflow_interrupt_value(request_payload.get("data")) + entry: dict[str, Any] = { + "id": str(request_payload["request_id"]), + "reason": "input_required", + "value": value, + "metadata": _workflow_interrupt_metadata(request_payload, value), + } + response_schema = _json_schema_for_response_type(getattr(request_event, "response_type", None)) + if response_schema is not None: + entry["responseSchema"] = response_schema + return entry def _interrupts_from_pending_requests(pending_events: dict[str, Any]) -> list[dict[str, Any]]: @@ -155,11 +219,67 @@ def _resume_to_workflow_responses(resume_payload: Any) -> dict[str, Any]: """Convert AG-UI resume payloads into workflow responses.""" responses: dict[str, Any] = {} for interrupt in _normalize_resume_interrupts(resume_payload): + if interrupt.get("status") not in {None, "resolved"}: + continue value = _coerce_json_value(interrupt.get("value")) responses[str(interrupt["id"])] = value return responses +def _resume_entries_to_workflow_responses(entries: list[dict[str, Any]]) -> dict[str, Any]: + """Convert validated resume entries into workflow responses.""" + responses: dict[str, Any] = {} + for entry in entries: + if entry.get("status") == "resolved": + responses[str(entry["interrupt_id"])] = _coerce_json_value(entry.get("payload")) + return responses + + +def _pending_workflow_interrupt_ids(pending_events: dict[str, Any]) -> set[str]: + """Return canonical interrupt ids for pending workflow request_info events.""" + pending_ids: set[str] = set() + for request_id, request_event in pending_events.items(): + pending_id = getattr(request_event, "request_id", None) or request_id + if pending_id: + pending_ids.add(str(pending_id)) + return pending_ids + + +def _resume_error_for_pending_workflow_requests( + resume_entries: list[dict[str, Any]], +) -> RunErrorEvent | None: + """Return a workflow resume error for explicit non-resolved canonical resume entries.""" + for entry in resume_entries: + interrupt_id = str(entry["interrupt_id"]) + status = entry.get("status") + if status == "cancelled": + return RunErrorEvent( + message=f"Workflow resume for interruptId '{interrupt_id}' was cancelled.", + code="WORKFLOW_RESUME_CANCELLED", + ) + if status not in {None, "resolved"}: + return RunErrorEvent( + message=f"Unsupported workflow resume status '{status}' for interruptId '{interrupt_id}'.", + code="WORKFLOW_RESUME_INVALID", + ) + return None + + +def _consume_cancelled_workflow_requests(workflow: Workflow, resume_entries: list[dict[str, Any]]) -> None: + """Remove cancelled workflow request_info events from the runner context.""" + cancelled_ids = {str(entry["interrupt_id"]) for entry in resume_entries if entry.get("status") == "cancelled"} + if not cancelled_ids: + return + + runner_context = getattr(workflow, "_runner_context", None) + pending_events = getattr(runner_context, "_pending_request_info_events", None) + if not isinstance(pending_events, dict): + return + + for interrupt_id in cancelled_ids: + pending_events.pop(interrupt_id, None) + + def _coerce_json_value(value: Any) -> Any: """Parse JSON strings when possible; otherwise return the original value.""" if not isinstance(value, str): @@ -385,7 +505,12 @@ def _coerce_responses_for_pending_requests( return responses normalized: dict[str, Any] = {} - pending_by_id = {str(request_id): event for request_id, event in pending_events.items()} + pending_by_id: dict[str, Any] = {} + for request_id, event in pending_events.items(): + pending_by_id[str(request_id)] = event + event_request_id = getattr(event, "request_id", None) + if event_request_id: + pending_by_id[str(event_request_id)] = event for request_id, value in responses.items(): request_key = str(request_id) @@ -412,6 +537,53 @@ def _coerce_responses_for_pending_requests( return normalized +def _coerce_responses_for_pending_requests_strict( + responses: dict[str, Any], + pending_events: dict[str, Any], +) -> tuple[dict[str, Any], RunErrorEvent | None]: + """Coerce resume responses or return RUN_ERROR for invalid pending response payloads.""" + if not responses or not pending_events: + return responses, None + + normalized: dict[str, Any] = {} + pending_by_id: dict[str, Any] = {} + for request_id, event in pending_events.items(): + pending_by_id[str(request_id)] = event + event_request_id = getattr(event, "request_id", None) + if event_request_id: + pending_by_id[str(event_request_id)] = event + + for request_id, value in responses.items(): + request_key = str(request_id) + request_event = pending_by_id.get(request_key) + if request_event is None: + normalized[request_key] = value + continue + + coerced_value = _coerce_response_for_request(request_event, value) + if coerced_value is None: + return ( + {}, + RunErrorEvent( + message=( + f"Workflow resume for interruptId '{request_key}' does not match expected " + f"response type {_response_type_name(request_event)}." + ), + code="WORKFLOW_RESUME_INVALID_RESPONSE", + ), + ) + if not _approval_response_matches_request(request_key, request_event, coerced_value): + return ( + {}, + RunErrorEvent( + message=f"Workflow resume for interruptId '{request_key}' does not match pending approval request.", + code="WORKFLOW_RESUME_INVALID_RESPONSE", + ), + ) + normalized[request_key] = coerced_value + return normalized, None + + def _latest_user_text(messages: list[Message]) -> str | None: """Get the most recent user text message, if present.""" for message in reversed(messages): @@ -577,15 +749,42 @@ async def run_workflow_stream( last_assistant_text: str | None = None resume_payload = _extract_resume_payload(input_data) - responses = _resume_to_workflow_responses(resume_payload) - responses.update(_extract_responses_from_messages(messages)) pending_before_run = await _pending_request_events(workflow) - responses = _coerce_responses_for_pending_requests(responses, pending_before_run) + pending_interrupt_ids = _pending_workflow_interrupt_ids(pending_before_run) + resume_entries: list[dict[str, Any]] = [] + if pending_interrupt_ids: + resume_entries, contract_error, contract_code = _resume_contract_error( + resume_payload, + pending_interrupt_ids, + required_code="WORKFLOW_RESUME_REQUIRED", + invalid_code="WORKFLOW_RESUME_INVALID", + unknown_code="WORKFLOW_RESUME_NOT_FOUND", + missing_code="WORKFLOW_RESUME_MISSING_INTERRUPT", + ) + if contract_error is not None and contract_code is not None: + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + yield RunErrorEvent(message=contract_error, code=contract_code) + return + resume_error = _resume_error_for_pending_workflow_requests(resume_entries) + if resume_error is not None: + if getattr(resume_error, "code", None) == "WORKFLOW_RESUME_CANCELLED": + _consume_cancelled_workflow_requests(workflow, resume_entries) + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + yield resume_error + return + + responses = ( + _resume_entries_to_workflow_responses(resume_entries) + if pending_interrupt_ids + else _resume_to_workflow_responses(resume_payload) + ) + responses.update(_extract_responses_from_messages(messages)) + responses, response_error = _coerce_responses_for_pending_requests_strict(responses, pending_before_run) + if response_error is not None: + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + yield response_error + return pending_interrupts = _interrupts_from_pending_requests(pending_before_run) - if not responses and pending_before_run: - responses.update(_single_pending_response_from_value(pending_before_run, resume_payload)) - if not responses and pending_before_run: - responses.update(_single_pending_response_from_value(pending_before_run, _latest_user_text(messages))) if not responses and pending_before_run: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) @@ -730,12 +929,9 @@ def _drain_open_message() -> list[TextMessageEndEvent]: if request_payload is None: continue request_id = request_payload["request_id"] - request_data = request_payload.get("data") - if isinstance(request_data, dict): - interrupt_value: Any = request_data - else: - interrupt_value = {"data": request_data} - interrupts.append({"id": str(request_id), "value": interrupt_value}) + interrupt_entry = _interrupt_entry_for_request_event(event) + if interrupt_entry is not None: + interrupts.append(interrupt_entry) args_delta = json.dumps(request_payload) yield ToolCallStartEvent(tool_call_id=str(request_id), tool_call_name="request_info") diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index d4caa856caf..937b104fc08 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -133,6 +133,30 @@ The server exposes endpoints at: - `/predictive_state_updates` - Document writing with `document_writer_agent` - `/subgraphs` - Travel planner with interrupt-driven flight/hotel choices via `subgraphs_agent` +### Interrupt and Resume Shape + +Human-in-the-loop and workflow examples use the canonical AG-UI protocol shape. A paused run finishes with +`RUN_FINISHED.outcome.type == "interrupt"` and renders prompts from `RUN_FINISHED.outcome.interrupts`; it does not +depend on a stable top-level `RUN_FINISHED.interrupt` field. + +Resume interrupted example threads with a canonical `resume` array: + +```json +{ + "threadId": "thread-1", + "messages": [], + "resume": [ + { + "interruptId": "interrupt_1", + "status": "resolved", + "payload": { + "approved": true + } + } + ] +} +``` + ### Complete FastAPI Example ```python diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index 71bd855c3a9..587baa1f351 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -131,6 +131,32 @@ Even without Agent wrapper, client-side tools work: - Server can also have its own tools - Hybrid execution works automatically +### Interrupts and Resume Entries + +Human-in-the-loop approvals and workflow input requests pause by emitting a terminal `RUN_FINISHED` event whose +`outcome.type` is `"interrupt"`. Generic AG-UI clients should read prompts from `RUN_FINISHED.outcome.interrupts` +and resume the same `threadId` with a canonical `resume` array of `ResumeEntry` values. + +```json +{ + "threadId": "thread-1", + "messages": [], + "resume": [ + { + "interruptId": "approval_1", + "status": "resolved", + "payload": { + "approved": true + } + } + ] +} +``` + +`Interrupt` and `ResumeEntry` are AG-UI protocol models from `ag_ui.core`; Agent Framework does not define a +separate interrupt model. New interrupted runs use `RUN_FINISHED.outcome.interrupts`, not a stable top-level +`RUN_FINISHED.interrupt` field. + ## What is AG-UI? AG-UI is a protocol that enables: diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 27525f4f32b..d7e53d8ca9a 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.10.0,<2", - "ag-ui-protocol>=0.1.16,<0.2", + "ag-ui-protocol>=0.1.19,<0.2", "fastapi>=0.115.0,<0.138.1", "uvicorn[standard]>=0.30.0,<1" ] diff --git a/python/packages/ag-ui/tests/ag_ui/event_stream.py b/python/packages/ag-ui/tests/ag_ui/event_stream.py index a6300c1042b..4833c34fcff 100644 --- a/python/packages/ag-ui/tests/ag_ui/event_stream.py +++ b/python/packages/ag-ui/tests/ag_ui/event_stream.py @@ -56,6 +56,29 @@ def messages_snapshot(self) -> list[Any]: """Return the latest MessagesSnapshotEvent messages list.""" return self.last("MESSAGES_SNAPSHOT").messages + def run_finished_interrupts(self, event: Any | None = None) -> list[dict[str, Any]]: + """Return canonical interrupts from a RUN_FINISHED event.""" + target = event or self.last("RUN_FINISHED") + dumped = self._event_dump(target) + assert "interrupt" not in dumped + outcome = dumped.get("outcome") + assert isinstance(outcome, dict), f"Expected RUN_FINISHED.outcome, got {dumped}" + assert outcome.get("type") == "interrupt" + interrupts = outcome.get("interrupts") + assert isinstance(interrupts, list), f"Expected outcome.interrupts, got {outcome}" + return interrupts + + @staticmethod + def interrupt_metadata_value(interrupt: dict[str, Any]) -> dict[str, Any]: + """Return Agent Framework interruption details from canonical interrupt metadata.""" + metadata = interrupt.get("metadata") + assert isinstance(metadata, dict) + agent_framework_metadata = metadata.get("agent_framework") + assert isinstance(agent_framework_metadata, dict) + value = agent_framework_metadata.get("value") + assert isinstance(value, dict) + return value + # ── Structural assertions ── def assert_bookends(self) -> None: @@ -173,3 +196,15 @@ def _type_str(event: Any) -> str: if isinstance(t, str): return t return getattr(t, "value", str(t)) + + @staticmethod + def _event_dump(event: Any) -> dict[str, Any]: + """Serialize an event object or return a raw event dict.""" + if isinstance(event, dict): + return event + if hasattr(event, "model_dump"): + return event.model_dump(by_alias=True, exclude_none=True) + raw = getattr(event, "raw", None) + if isinstance(raw, dict): + return raw + raise TypeError(f"Unsupported event type: {type(event).__name__}") diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_hitl.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_hitl.py index 2570bd5c313..70f0f4443c9 100644 --- a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_hitl.py +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_hitl.py @@ -86,9 +86,7 @@ async def test_hitl_turn1_golden_sequence() -> None: assert "confirm_changes" in tool_names # RUN_FINISHED should have interrupt metadata - finished = stream.last("RUN_FINISHED") - interrupt = getattr(finished, "interrupt", None) - assert interrupt is not None, "Expected interrupt in RUN_FINISHED" + interrupt = stream.run_finished_interrupts() assert len(interrupt) > 0 @@ -192,5 +190,5 @@ async def test_hitl_turn2_resume_with_approval() -> None: # RUN_FINISHED should NOT have interrupt (approval completed) finished = stream.last("RUN_FINISHED") - interrupt = getattr(finished, "interrupt", None) - assert not interrupt, f"Expected no interrupt after approval, got {interrupt}" + dumped = finished.model_dump(by_alias=True, exclude_none=True) + assert "outcome" not in dumped, f"Expected no interrupt after approval, got {dumped.get('outcome')}" diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_subgraphs.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_subgraphs.py index 594bfea7763..59935de8aa3 100644 --- a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_subgraphs.py +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_subgraphs.py @@ -78,13 +78,11 @@ async def test_subgraphs_turn1_interrupt_structure() -> None: }, ) - finished = stream.last("RUN_FINISHED") - interrupt = getattr(finished, "interrupt", None) - assert interrupt is not None, "Expected interrupt in RUN_FINISHED" - assert isinstance(interrupt, list) + interrupt = stream.run_finished_interrupts() assert len(interrupt) > 0 - assert interrupt[0]["value"]["agent"] == "flights" - assert len(interrupt[0]["value"]["options"]) == 2 + interrupt_value = stream.interrupt_metadata_value(interrupt[0]) + assert interrupt_value["agent"] == "flights" + assert len(interrupt_value["options"]) == 2 async def test_subgraphs_turn1_text_messages_balanced() -> None: @@ -143,8 +141,7 @@ async def test_subgraphs_full_flow_event_ordering() -> None: stream1.assert_no_run_error() # Extract flight interrupt - finished1 = stream1.last("RUN_FINISHED") - interrupt1 = finished1.model_dump()["interrupt"][0] + interrupt1 = stream1.run_finished_interrupts()[0] # Turn 2: Select flight stream2 = await _run( @@ -174,9 +171,8 @@ async def test_subgraphs_full_flow_event_ordering() -> None: stream2.assert_no_run_error() # Should now have hotel interrupt - finished2 = stream2.last("RUN_FINISHED") - interrupt2 = finished2.model_dump()["interrupt"] - assert interrupt2[0]["value"]["agent"] == "hotels" + interrupt2 = stream2.run_finished_interrupts() + assert stream2.interrupt_metadata_value(interrupt2[0])["agent"] == "hotels" # Turn 3: Select hotel stream3 = await _run( @@ -207,5 +203,5 @@ async def test_subgraphs_full_flow_event_ordering() -> None: # Final turn should not have interrupt finished3 = stream3.last("RUN_FINISHED") - final_interrupt = getattr(finished3, "interrupt", None) - assert not final_interrupt, f"Expected no interrupt after completion, got {final_interrupt}" + final_dump = finished3.model_dump(by_alias=True, exclude_none=True) + assert "outcome" not in final_dump, f"Expected no interrupt after completion, got {final_dump.get('outcome')}" diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py index 81669654e5a..1f8da4b13b1 100644 --- a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py @@ -303,7 +303,7 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: async def test_workflow_request_info_interrupt_in_run_finished() -> None: - """request_info populates RUN_FINISHED.interrupt with the request metadata.""" + """request_info populates RUN_FINISHED.outcome.interrupts with the request metadata.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -317,12 +317,10 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: wrapper = AgentFrameworkWorkflow(workflow=workflow) stream = await _run(wrapper, _payload()) - finished = stream.last("RUN_FINISHED") - interrupt = finished.model_dump().get("interrupt") - assert isinstance(interrupt, list) + interrupt = stream.run_finished_interrupts() assert len(interrupt) == 1 assert interrupt[0]["id"] == "flights-choice" - assert interrupt[0]["value"]["agent"] == "flights" + assert stream.interrupt_metadata_value(interrupt[0])["agent"] == "flights" async def test_workflow_request_info_emits_interrupt_card_event() -> None: @@ -515,8 +513,7 @@ async def handle_choice(self, original: str, response: str, ctx: WorkflowContext stream1.assert_no_run_error() stream1.assert_tool_calls_balanced() - finished1 = stream1.last("RUN_FINISHED") - interrupt1 = finished1.model_dump().get("interrupt") + interrupt1 = stream1.run_finished_interrupts() assert interrupt1, "Expected interrupt" assert interrupt1[0]["id"] == "choice-1" @@ -540,12 +537,12 @@ async def handle_choice(self, original: str, response: str, ctx: WorkflowContext # No interrupt after resume finished2 = stream2.last("RUN_FINISHED") - interrupt2 = finished2.model_dump().get("interrupt") - assert not interrupt2 + dumped2 = finished2.model_dump(by_alias=True, exclude_none=True) + assert "outcome" not in dumped2 async def test_workflow_forwarded_props_resume() -> None: - """CopilotKit-style forwarded_props.command.resume should resume a pending request.""" + """forwarded_props.command.resume should resume with canonical resume entries.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -564,14 +561,16 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: "thread_id": "thread-fwd", "run_id": "run-2", "messages": [], - "forwarded_props": {"command": {"resume": json.dumps({"name": "A"})}}, + "forwarded_props": { + "command": {"resume": [{"interruptId": "pick", "status": "resolved", "payload": {"name": "A"}}]} + }, }, ) stream2.assert_bookends() stream2.assert_no_run_error() finished = stream2.last("RUN_FINISHED") - assert not finished.model_dump().get("interrupt") + assert "outcome" not in finished.model_dump(by_alias=True, exclude_none=True) # ────────────────────────────────────────────────────────────────────── @@ -579,8 +578,8 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: # ────────────────────────────────────────────────────────────────────── -async def test_workflow_empty_turn_preserves_interrupts() -> None: - """An empty turn with a pending request still returns the interrupt without errors.""" +async def test_workflow_empty_turn_with_pending_request_emits_run_error() -> None: + """An empty turn with a pending request must provide canonical resume entries.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -601,18 +600,9 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: "messages": [], }, ) - stream2.assert_bookends() - stream2.assert_no_run_error() - stream2.assert_tool_calls_balanced() - - # Should re-emit the pending interrupt - finished = stream2.last("RUN_FINISHED") - interrupts = finished.model_dump().get("interrupt") - assert isinstance(interrupts, list) - assert interrupts[0]["id"] == "pick-one" - - # Should have TOOL_CALL events for the pending request - stream2.assert_has_type("TOOL_CALL_START") + stream2.assert_has_type("RUN_STARTED") + run_error = stream2.last("RUN_ERROR") + assert run_error.code == "WORKFLOW_RESUME_REQUIRED" async def test_workflow_empty_turn_no_pending_requests() -> None: @@ -703,10 +693,9 @@ async def handle_approval(self, original_request: Content, response: Content, ct stream1.assert_bookends() stream1.assert_no_run_error() - finished1 = stream1.last("RUN_FINISHED") - interrupt1 = finished1.model_dump().get("interrupt") + interrupt1 = stream1.run_finished_interrupts() assert interrupt1, "Expected approval interrupt" - interrupt_value = interrupt1[0]["value"] + interrupt_value = stream1.interrupt_metadata_value(interrupt1[0]) # Turn 2: approve stream2 = await _run( @@ -739,7 +728,7 @@ async def handle_approval(self, original_request: Content, response: Content, ct # No more interrupt finished2 = stream2.last("RUN_FINISHED") - assert not finished2.model_dump().get("interrupt") + assert "outcome" not in finished2.model_dump(by_alias=True, exclude_none=True) # ────────────────────────────────────────────────────────────────────── @@ -802,7 +791,7 @@ async def handle_input(self, original: dict, response: list[Message], ctx: Workf async def test_workflow_plain_text_does_not_resume_pending_dict_request() -> None: - """Plain text user follow-up should NOT be coerced into a dict response.""" + """Plain text user follow-up should fail instead of being coerced into a dict response.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -840,14 +829,9 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: ], }, ) - stream2.assert_bookends() - stream2.assert_no_run_error() - - # Should still have the interrupt (text was not accepted as dict response) - finished = stream2.last("RUN_FINISHED") - interrupts = finished.model_dump().get("interrupt") - assert isinstance(interrupts, list) - assert interrupts[0]["id"] == "flights-choice" + stream2.assert_has_type("RUN_STARTED") + run_error = stream2.last("RUN_ERROR") + assert run_error.code == "WORKFLOW_RESUME_REQUIRED" # ────────────────────────────────────────────────────────────────────── @@ -924,7 +908,7 @@ async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[s stream1 = await _run(wrapper, _payload(thread_id="thread-seq", run_id="run-1")) stream1.assert_bookends() stream1.assert_tool_calls_balanced() - interrupt1 = stream1.last("RUN_FINISHED").model_dump().get("interrupt") + interrupt1 = stream1.run_finished_interrupts() assert interrupt1[0]["id"] == "name-req" # Turn 2: answer name → triggers second executor's request_info @@ -939,7 +923,7 @@ async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[s ) stream2.assert_has_run_lifecycle() stream2.assert_tool_calls_balanced() - interrupt2 = stream2.last("RUN_FINISHED").model_dump().get("interrupt") + interrupt2 = stream2.run_finished_interrupts() assert interrupt2[0]["id"] == "dest-req" # Turn 3: answer destination → completion @@ -958,4 +942,4 @@ async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[s deltas = [e.delta for e in stream3.get("TEXT_MESSAGE_CONTENT")] assert any("Alice" in d and "Paris" in d for d in deltas) - assert not stream3.last("RUN_FINISHED").model_dump().get("interrupt") + assert "outcome" not in stream3.last("RUN_FINISHED").model_dump(by_alias=True, exclude_none=True) diff --git a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py index a04d13ab1c1..0d385779497 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py +++ b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, Awaitable, MutableSequence from typing import Any +from ag_ui.core import Interrupt, ResumeEntry from agent_framework import ( ChatOptions, ChatResponse, @@ -443,7 +444,9 @@ async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str async def test_interrupt_options_transmission(self, monkeypatch: MonkeyPatch) -> None: """Interrupt option fields are forwarded to the HTTP service.""" available_interrupts = [{"id": "req_1", "type": "request_info"}] + expected_available_interrupts = [{"id": "req_1", "reason": "input_required"}] resume_payload = {"interrupts": [{"id": "req_1", "value": "approved"}]} + expected_resume_payload = [{"interruptId": "req_1", "status": "resolved", "payload": "approved"}] mock_events = [ {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, @@ -451,8 +454,8 @@ async def test_interrupt_options_transmission(self, monkeypatch: MonkeyPatch) -> ] async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: - assert kwargs.get("available_interrupts") == available_interrupts - assert kwargs.get("resume") == resume_payload + assert kwargs.get("available_interrupts") == expected_available_interrupts + assert kwargs.get("resume") == expected_resume_payload for event in mock_events: yield event @@ -467,3 +470,47 @@ async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str response = await client.inner_get_response(messages=messages, options=options) assert response is not None + + async def test_typed_interrupt_options_forward_canonical_protocol_shape(self, monkeypatch: MonkeyPatch) -> None: + """Typed interrupt options are forwarded as canonical protocol JSON.""" + mock_events = [ + {"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"}, + {"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"}, + ] + + async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]: + assert kwargs.get("available_interrupts") == [ + { + "id": "approval_1", + "reason": "tool_call", + "toolCallId": "call_1", + "responseSchema": {"type": "object"}, + } + ] + assert kwargs.get("resume") == [ + {"interruptId": "approval_1", "status": "resolved", "payload": {"approved": True}} + ] + for event in mock_events: + yield event + + client = StubAGUIChatClient(endpoint="http://localhost:8888/") + monkeypatch.setattr(client.http_service, "post_run", mock_post_run) + + options: dict[str, Any] = { + "available_interrupts": [ + Interrupt( + id="approval_1", + reason="tool_call", + tool_call_id="call_1", + response_schema={"type": "object"}, + ) + ], + "resume": [ResumeEntry(interrupt_id="approval_1", status="resolved", payload={"approved": True})], + } + + response = await client.inner_get_response( + messages=[Message(role="user", contents=["continue"])], + options=options, + ) + + assert response is not None diff --git a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py index f6272ab86e8..8e6bf7e3c6a 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py @@ -803,28 +803,10 @@ async def stream_fn_turn2( tools=[get_datetime], ) - tool_result: dict[str, Any] = {"accepted": True} input_data: dict[str, Any] = { "thread_id": thread_id, - "messages": [ - {"role": "user", "content": "What time is it?"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_get_datetime_123", - "type": "function", - "function": {"name": "get_datetime", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "content": json.dumps(tool_result), - "toolCallId": "call_get_datetime_123", - }, - ], + "messages": [], + "resume": [{"interruptId": "call_get_datetime_123", "status": "resolved", "payload": {"accepted": True}}], } events2: list[Any] = [] @@ -887,37 +869,12 @@ async def stream_fn( thread_id = "thread-rejection-test" # Pre-populate the pending approval as if Turn 1 had emitted the request. - wrapper._pending_approvals[f"{thread_id}:call_delete_123"] = "delete_all_data" + wrapper._pending_approvals[(thread_id, "call_delete_123")] = "delete_all_data" - # Simulate rejection - tool_result: dict[str, Any] = {"accepted": False} input_data: dict[str, Any] = { "thread_id": thread_id, - "messages": [ - { - "role": "user", - "content": "Delete all my data", - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_delete_123", - "type": "function", - "function": { - "name": "delete_all_data", - "arguments": "{}", - }, - } - ], - }, - { - "role": "tool", - "content": json.dumps(tool_result), - "toolCallId": "call_delete_123", - }, - ], + "messages": [], + "resume": [{"interruptId": "call_delete_123", "status": "resolved", "payload": {"accepted": False}}], } events: list[Any] = [] @@ -1103,22 +1060,8 @@ async def stream_fn_post_approval( turn2_input: dict[str, Any] = { "thread_id": thread_id, - "messages": [ - {"role": "user", "content": "do it"}, - { - "role": "user", - "content": "approved", - "function_approvals": [ - { - "id": "call_sens_001", - "call_id": "call_sens_001", - "name": "sensitive_action", - "approved": True, - "arguments": {}, - } - ], - }, - ], + "messages": [], + "resume": [{"interruptId": "call_sens_001", "status": "resolved", "payload": {"accepted": True}}], } events2: list[Any] = [] @@ -1133,21 +1076,8 @@ async def stream_fn_post_approval( turn3_input: dict[str, Any] = { "thread_id": thread_id, - "messages": [ - { - "role": "user", - "content": "replay", - "function_approvals": [ - { - "id": "call_sens_001", - "call_id": "call_sens_001", - "name": "sensitive_action", - "approved": True, - "arguments": {}, - } - ], - }, - ], + "messages": [], + "resume": [{"interruptId": "call_sens_001", "status": "resolved", "payload": {"accepted": True}}], } events3: list[Any] = [] diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index f675992e1b1..74d68bf39d2 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -9,13 +9,19 @@ from ag_ui.core import MessagesSnapshotEvent, RunStartedEvent, StateSnapshotEvent from agent_framework import ( Agent, + AgentResponseUpdate, ChatResponseUpdate, Content, + Executor, + FunctionTool, WorkflowBuilder, WorkflowContext, executor, + handler, + response_handler, ) from agent_framework.orchestrations import SequentialBuilder +from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] from fastapi import FastAPI, Header, HTTPException from fastapi.params import Depends from fastapi.testclient import TestClient @@ -30,6 +36,28 @@ def _decode_sse_events(response: Any) -> list[dict[str, Any]]: return [json.loads(line[6:]) for line in content.splitlines() if line.startswith("data: ")] +def _run_finished_interrupts(event: dict[str, Any]) -> list[dict[str, Any]]: + """Return canonical interrupts from an SSE RUN_FINISHED event.""" + assert "interrupt" not in event + outcome = event.get("outcome") + assert isinstance(outcome, dict) + assert outcome.get("type") == "interrupt" + interrupts = outcome.get("interrupts") + assert isinstance(interrupts, list) + return cast(list[dict[str, Any]], interrupts) + + +def _interrupt_metadata_value(interrupt: dict[str, Any]) -> dict[str, Any]: + """Return Agent Framework details from canonical interrupt metadata.""" + metadata = interrupt.get("metadata") + assert isinstance(metadata, dict) + agent_framework_metadata = metadata.get("agent_framework") + assert isinstance(agent_framework_metadata, dict) + value = agent_framework_metadata.get("value") + assert isinstance(value, dict) + return cast(dict[str, Any], value) + + def _latest_messages_snapshot(response: Any) -> list[dict[str, Any]]: snapshots = [ event["messages"] for event in _decode_sse_events(response) if event.get("type") == "MESSAGES_SNAPSHOT" @@ -83,7 +111,7 @@ async def test_add_endpoint_with_workflow_protocol(): @executor(id="start") async def start(message: Any, ctx: WorkflowContext[Any, Any]) -> None: - await ctx.yield_output("Workflow response") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + await ctx.yield_output("Workflow response") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] app = FastAPI() workflow = WorkflowBuilder(start_executor=start).build() @@ -215,6 +243,776 @@ async def test_endpoint_event_streaming(build_chat_client): assert found_run_finished +async def test_endpoint_agent_approval_pause_emits_canonical_interrupt_outcome(): + """Approval pauses should finish with canonical AG-UI interrupt outcomes over SSE.""" + app = FastAPI() + function_call = Content.from_function_call( + call_id="call_write_doc", + name="write_doc", + arguments={"content": "Draft"}, + ) + approval_request = Content.from_function_approval_request( + id="call_write_doc", + function_call=function_call, + ) + agent = StubAgent(updates=[AgentResponseUpdate(contents=[approval_request], role="assistant")]) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + client = TestClient(app) + response = client.post( + "/approval", + json={ + "runId": "run-approval", + "threadId": "thread-approval", + "messages": [{"role": "user", "content": "Write a draft"}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + finished = [event for event in events if event.get("type") == "RUN_FINISHED"] + assert len(finished) == 1 + interrupts = _run_finished_interrupts(finished[0]) + assert len(interrupts) == 1 + + interrupt = interrupts[0] + assert interrupt["id"] == "call_write_doc" + assert interrupt["reason"] == "tool_call" + assert interrupt["toolCallId"] == "call_write_doc" + assert interrupt["message"] == "Approve running write_doc?" + assert interrupt["responseSchema"]["required"] == ["accepted"] + assert interrupt["responseSchema"]["properties"]["accepted"]["type"] == "boolean" + assert interrupt["responseSchema"]["properties"]["content"]["type"] == "string" + metadata_value = interrupt["metadata"]["agent_framework"] + assert metadata_value["type"] == "function_approval_request" + assert metadata_value["function_call"] == { + "call_id": "call_write_doc", + "name": "write_doc", + "arguments": {"content": "Draft"}, + } + + +def _build_weather_approval_endpoint() -> tuple[TestClient, StubAgent, list[str]]: + executed_cities: list[str] = [] + + def get_weather(city: str) -> str: + executed_cities.append(city) + return f"Sunny in {city}" + + weather_tool = FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + function_call = Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ) + approval_request = Content.from_function_approval_request( + id="call_get_weather", + function_call=function_call, + ) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[approval_request], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + client = TestClient(app) + pause_response = client.post( + "/approval", + json={ + "runId": "run-pause", + "threadId": "thread-weather", + "messages": [{"role": "user", "content": "What is the weather?"}], + }, + ) + assert pause_response.status_code == 200 + pause_events = _decode_sse_events(pause_response) + pause_finished = [event for event in pause_events if event.get("type") == "RUN_FINISHED"] + assert pause_finished + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == "call_get_weather" + + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + return client, agent, executed_cities + + +async def test_endpoint_agent_approval_resume_entry_executes_approved_tool(): + """A resolved canonical approval resume should execute the pending approved tool.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-resume", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_get_weather", + "status": "resolved", + "payload": {"accepted": True}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + tool_results = [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + assert len(tool_results) == 1 + assert tool_results[0]["toolCallId"] == "call_get_weather" + assert tool_results[0]["content"] == "Sunny in Seattle" + assert executed_cities == ["Seattle"] + assert "outcome" not in [event for event in events if event.get("type") == "RUN_FINISHED"][-1] + + +async def test_endpoint_agent_approval_resume_entry_denial_does_not_execute_tool(): + """A resolved canonical denial resume should not execute the pending tool.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-deny", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_get_weather", + "status": "resolved", + "payload": {"accepted": False}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert not [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + assert executed_cities == [] + assert [event for event in events if event.get("type") == "RUN_FINISHED"] + + +async def test_endpoint_agent_approval_resume_entry_applies_edited_arguments(): + """A resolved canonical approval resume should apply advertised edited arguments.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-edit", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_get_weather", + "status": "resolved", + "payload": {"accepted": True, "city": "Portland"}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + tool_results = [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + assert len(tool_results) == 1 + assert tool_results[0]["content"] == "Sunny in Portland" + assert executed_cities == ["Portland"] + + +async def test_endpoint_agent_approval_cancelled_resume_entry_emits_run_error(): + """A cancelled canonical approval resume should fail safely instead of proceeding.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-cancel", + "threadId": "thread-weather", + "messages": [], + "resume": [{"interruptId": "call_get_weather", "status": "cancelled"}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_CANCELLED" + assert executed_cities == [] + assert not [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + + +async def test_endpoint_agent_approval_unknown_resume_entry_emits_run_error(): + """A canonical approval resume for an unknown pending interrupt should fail safely.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-forged", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_forged", + "status": "resolved", + "payload": {"accepted": True}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_NOT_FOUND" + assert executed_cities == [] + assert not [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + + +async def test_endpoint_agent_approval_resume_with_lost_registry_emits_run_error(): + """A stored approval interrupt cannot resume if the server-side validation registry was lost.""" + executed_cities: list[str] = [] + + def get_weather(city: str) -> str: + executed_cities.append(city) + return f"Sunny in {city}" + + weather_tool = FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + approval_request = Content.from_function_approval_request( + id="call_get_weather", + function_call=Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ), + ) + agent = StubAgent( + updates=[ + AgentResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ) + ], + role="assistant", + ), + AgentResponseUpdate(contents=[approval_request], role="assistant"), + ], + default_options={"tools": [weather_tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval-snapshots", + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + + pause_response = client.post( + "/approval-snapshots", + json={ + "thread_id": "thread-weather", + "messages": [{"role": "user", "content": "What is the weather?"}], + }, + ) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == "call_get_weather" + + wrapped_agent._pending_approvals.clear() + + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Should not run")], role="assistant")] + response = client.post( + "/approval-snapshots", + json={ + "runId": "run-lost-registry", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_get_weather", + "status": "resolved", + "payload": {"accepted": True}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_NOT_FOUND" + assert executed_cities == [] + assert not [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + + +async def test_endpoint_agent_approval_new_input_with_pending_interrupt_emits_run_error(): + """New non-resume input on an approval-interrupted thread must fail with RUN_ERROR.""" + client, agent, executed_cities = _build_weather_approval_endpoint() + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Should not run")], role="assistant")] + + response = client.post( + "/approval", + json={ + "runId": "run-new-input", + "threadId": "thread-weather", + "messages": [{"role": "user", "content": "Actually, what about Portland?"}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_REQUIRED" + assert executed_cities == [] + assert not [event for event in events if event.get("type") == "TEXT_MESSAGE_CONTENT"] + + +async def test_endpoint_agent_approval_malformed_resume_entry_emits_run_error(): + """Malformed resume entries hidden in forwarded props must fail as stream RUN_ERROR events.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-malformed", + "threadId": "thread-weather", + "messages": [], + "forwardedProps": {"command": {"resume": [{"status": "resolved", "payload": {"accepted": True}}]}}, + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_INVALID" + assert executed_cities == [] + + +async def test_endpoint_agent_approval_resume_omitting_pending_interrupt_emits_run_error(): + """A resume must address every open approval interrupt exactly once.""" + executed: list[str] = [] + + def record_city(city: str) -> str: + executed.append(city) + return f"Recorded {city}" + + tool = FunctionTool( + name="record_city", + description="Record a city", + func=record_city, + approval_mode="always_require", + ) + approval_requests = [ + Content.from_function_approval_request( + id="call_seattle", + function_call=Content.from_function_call( + call_id="call_seattle", + name="record_city", + arguments={"city": "Seattle"}, + ), + ), + Content.from_function_approval_request( + id="call_portland", + function_call=Content.from_function_call( + call_id="call_portland", + name="record_city", + arguments={"city": "Portland"}, + ), + ), + ] + agent = StubAgent( + updates=[AgentResponseUpdate(contents=approval_requests, role="assistant")], + default_options={"tools": [tool]}, + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + AgentFrameworkAgent(agent=agent, require_confirmation=False), + path="/approval", + ) + client = TestClient(app) + pause_response = client.post( + "/approval", + json={ + "runId": "run-pause", + "threadId": "thread-two-approvals", + "messages": [{"role": "user", "content": "Record two cities"}], + }, + ) + assert pause_response.status_code == 200 + + response = client.post( + "/approval", + json={ + "runId": "run-partial", + "threadId": "thread-two-approvals", + "messages": [], + "resume": [{"interruptId": "call_seattle", "status": "resolved", "payload": {"accepted": True}}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_MISSING_INTERRUPT" + assert executed == [] + + +async def test_endpoint_agent_approval_cancelled_resume_preserves_uncancelled_interrupt(): + """Cancelling one approval clears only that interrupt and leaves others resumable.""" + executed: list[str] = [] + + def record_city(city: str) -> str: + executed.append(city) + return f"Recorded {city}" + + tool = FunctionTool( + name="record_city", + description="Record a city", + func=record_city, + approval_mode="always_require", + ) + approval_requests = [ + Content.from_function_approval_request( + id="call_seattle", + function_call=Content.from_function_call( + call_id="call_seattle", + name="record_city", + arguments={"city": "Seattle"}, + ), + ), + Content.from_function_approval_request( + id="call_portland", + function_call=Content.from_function_call( + call_id="call_portland", + name="record_city", + arguments={"city": "Portland"}, + ), + ), + ] + agent = StubAgent( + updates=[AgentResponseUpdate(contents=approval_requests, role="assistant")], + default_options={"tools": [tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval-snapshots", + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + pause_response = client.post( + "/approval-snapshots", + json={ + "runId": "run-pause", + "threadId": "thread-two-approvals", + "messages": [{"role": "user", "content": "Record two cities"}], + }, + ) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert {interrupt["id"] for interrupt in _run_finished_interrupts(pause_finished[-1])} == { + "call_seattle", + "call_portland", + } + + cancel_response = client.post( + "/approval-snapshots", + json={ + "runId": "run-cancel-one", + "threadId": "thread-two-approvals", + "messages": [], + "resume": [ + {"interruptId": "call_seattle", "status": "cancelled"}, + {"interruptId": "call_portland", "status": "resolved", "payload": {"accepted": True}}, + ], + }, + ) + + assert cancel_response.status_code == 200 + cancel_events = _decode_sse_events(cancel_response) + run_errors = [event for event in cancel_events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_RESUME_CANCELLED" + assert executed == [] + assert not any("call_seattle" in key for key in wrapped_agent._pending_approvals) + assert any("call_portland" in key for key in wrapped_agent._pending_approvals) + + hydrate_response = client.post( + "/approval-snapshots", + json={"threadId": "thread-two-approvals", "messages": []}, + ) + assert hydrate_response.status_code == 200 + hydrate_events = _decode_sse_events(hydrate_response) + assert [interrupt["id"] for interrupt in _run_finished_interrupts(hydrate_events[-1])] == ["call_portland"] + + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + resume_response = client.post( + "/approval-snapshots", + json={ + "runId": "run-resume-remaining", + "threadId": "thread-two-approvals", + "messages": [], + "resume": [{"interruptId": "call_portland", "status": "resolved", "payload": {"accepted": True}}], + }, + ) + + assert resume_response.status_code == 200 + assert executed == ["Portland"] + resume_events = _decode_sse_events(resume_response) + assert not [event for event in resume_events if event.get("type") == "RUN_ERROR"] + + +def _build_workflow_request_info_app() -> FastAPI: + class FlightChoiceExecutor(Executor): + def __init__(self) -> None: + super().__init__(id="flight_choice") + + @handler + async def start(self, message: Any, ctx: WorkflowContext[Any, Any]) -> None: + del message + await ctx.request_info( + {"message": "Choose a flight", "options": [{"airline": "KLM"}, {"airline": "United"}]}, + dict, + request_id="flight-choice", + ) + + @response_handler + async def handle_choice(self, original_request: dict, response: dict, ctx: WorkflowContext[Any, Any]) -> None: + del original_request + await ctx.yield_output(f"Booked {response['airline']}") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] + + app = FastAPI() + workflow = WorkflowBuilder(start_executor=FlightChoiceExecutor()).build() + add_agent_framework_fastapi_endpoint(app, workflow, path="/workflow") + return app + + +async def test_endpoint_workflow_request_info_emits_canonical_interrupt_and_resumes(): + """Workflow request_info pauses and resumes through canonical AG-UI interrupt payloads.""" + app = _build_workflow_request_info_app() + + with TestClient(app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + + assert pause_response.status_code == 200 + pause_events = _decode_sse_events(pause_response) + pause_finished = [event for event in pause_events if event.get("type") == "RUN_FINISHED"] + assert len(pause_finished) == 1 + interrupts = _run_finished_interrupts(pause_finished[0]) + assert len(interrupts) == 1 + interrupt = interrupts[0] + assert interrupt["id"] == "flight-choice" + assert interrupt["reason"] == "input_required" + assert interrupt["message"] == "Choose a flight" + assert interrupt["responseSchema"]["type"] == "object" + assert interrupt["metadata"]["agent_framework"]["type"] == "workflow_request_info" + assert interrupt["metadata"]["agent_framework"]["request_id"] == "flight-choice" + + resume_response = client.post( + "/workflow", + json={ + "runId": "run-resume", + "threadId": "thread-flights", + "messages": [], + "resume": [ + { + "interruptId": "flight-choice", + "status": "resolved", + "payload": {"airline": "KLM"}, + } + ], + }, + ) + + assert resume_response.status_code == 200 + resume_events = _decode_sse_events(resume_response) + assert not [event for event in resume_events if event.get("type") == "RUN_ERROR"] + text_deltas = [event["delta"] for event in resume_events if event.get("type") == "TEXT_MESSAGE_CONTENT"] + assert "Booked KLM" in text_deltas + assert "outcome" not in [event for event in resume_events if event.get("type") == "RUN_FINISHED"][-1] + + +async def test_endpoint_workflow_request_info_cancelled_resume_emits_run_error(): + """Cancelled workflow resumes fail explicitly and do not wedge the next turn.""" + app = _build_workflow_request_info_app() + + with TestClient(app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + assert pause_response.status_code == 200 + + resume_response = client.post( + "/workflow", + json={ + "runId": "run-cancel", + "threadId": "thread-flights", + "messages": [], + "resume": [{"interruptId": "flight-choice", "status": "cancelled"}], + }, + ) + + assert resume_response.status_code == 200 + events = _decode_sse_events(resume_response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "WORKFLOW_RESUME_CANCELLED" + assert not [event for event in events if event.get("type") == "TEXT_MESSAGE_CONTENT"] + + next_response = client.post( + "/workflow", + json={ + "runId": "run-after-cancel", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book a different flight"}], + }, + ) + + assert next_response.status_code == 200 + next_events = _decode_sse_events(next_response) + assert not [event for event in next_events if event.get("type") == "RUN_ERROR"] + next_finished = [event for event in next_events if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(next_finished[-1])[0]["id"] == "flight-choice" + + +async def test_endpoint_workflow_request_info_new_input_with_pending_interrupt_emits_run_error(): + """New non-resume input on a workflow-interrupted thread must fail with RUN_ERROR.""" + app = _build_workflow_request_info_app() + + with TestClient(app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + assert pause_response.status_code == 200 + + response = client.post( + "/workflow", + json={ + "runId": "run-new-input", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "I prefer KLM"}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "WORKFLOW_RESUME_REQUIRED" + assert not [event for event in events if event.get("type") == "TEXT_MESSAGE_CONTENT"] + + +async def test_endpoint_workflow_request_info_malformed_resume_entry_emits_run_error(): + """Malformed workflow resume entries must fail as observable stream RUN_ERROR events.""" + app = _build_workflow_request_info_app() + + with TestClient(app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + assert pause_response.status_code == 200 + + response = client.post( + "/workflow", + json={ + "runId": "run-malformed", + "threadId": "thread-flights", + "messages": [], + "forwardedProps": {"command": {"resume": [{"status": "resolved", "payload": {"airline": "KLM"}}]}}, + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "WORKFLOW_RESUME_INVALID" + + +async def test_endpoint_workflow_request_info_invalid_response_payload_emits_run_error(): + """Workflow resume payloads that fail declared response-schema coercion must RUN_ERROR.""" + app = _build_workflow_request_info_app() + + with TestClient(app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "thread-flights", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + assert pause_response.status_code == 200 + + response = client.post( + "/workflow", + json={ + "runId": "run-invalid-payload", + "threadId": "thread-flights", + "messages": [], + "resume": [{"interruptId": "flight-choice", "status": "resolved", "payload": "KLM"}], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + run_errors = [event for event in events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "WORKFLOW_RESUME_INVALID_RESPONSE" + + async def test_endpoint_with_workflow_as_agent_stream_output(build_chat_client): """Test endpoint handles workflow-as-agent stream outputs.""" app = FastAPI() @@ -922,7 +1720,8 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): assert call_count == 1 first_events = _decode_sse_events(first_response) first_finished = [event for event in first_events if event.get("type") == "RUN_FINISHED"] - assert first_finished[-1]["interrupt"][0]["value"]["function_call"]["call_id"] == "draft-call" + first_interrupts = _run_finished_interrupts(first_finished[-1]) + assert _interrupt_metadata_value(first_interrupts[0])["function_call"]["call_id"] == "draft-call" hydrate_response = client.post("/snapshots", json={"thread_id": "agent-thread", "messages": []}) @@ -936,7 +1735,8 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): "RUN_FINISHED", ] assert events[1]["snapshot"] == {"steps": [{"description": "Draft outline"}]} - assert events[-1]["interrupt"][0]["value"]["function_call"]["name"] == "draft_steps" + hydrated_interrupts = _run_finished_interrupts(events[-1]) + assert _interrupt_metadata_value(hydrated_interrupts[0])["function_call"]["name"] == "draft_steps" async def test_agent_endpoint_run_error_does_not_overwrite_previous_snapshot(streaming_chat_client_stub): @@ -1145,7 +1945,8 @@ async def requester(message: Any, ctx: WorkflowContext[Any, Any]) -> None: assert first_response.status_code == 200 assert call_count == 1 first_finished = [event for event in _decode_sse_events(first_response) if event.get("type") == "RUN_FINISHED"] - assert first_finished[-1]["interrupt"][0]["id"] == "workflow-approval" + first_interrupts = _run_finished_interrupts(first_finished[-1]) + assert first_interrupts[0]["id"] == "workflow-approval" hydrate_response = client.post("/workflow-snapshots", json={"thread_id": "workflow-thread", "messages": []}) @@ -1159,8 +1960,9 @@ async def requester(message: Any, ctx: WorkflowContext[Any, Any]) -> None: "RUN_FINISHED", ] assert events[1]["snapshot"] == {"step": "approval"} - assert events[-1]["interrupt"][0]["id"] == "workflow-approval" - assert events[-1]["interrupt"][0]["value"]["message"] == "Approve workflow step" + hydrated_interrupts = _run_finished_interrupts(events[-1]) + assert hydrated_interrupts[0]["id"] == "workflow-approval" + assert hydrated_interrupts[0]["message"] == "Approve workflow step" async def test_workflow_endpoint_run_error_does_not_overwrite_previous_snapshot(): @@ -1305,15 +2107,21 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): assert call_count == 1 first_events = _decode_sse_events(first_response) first_finished = [event for event in first_events if event.get("type") == "RUN_FINISHED"] - assert first_finished[-1]["interrupt"] - confirm_call_id = first_finished[-1]["interrupt"][0]["id"] + first_interrupts = _run_finished_interrupts(first_finished[-1]) + confirm_call_id = first_interrupts[0]["id"] confirm_response = client.post( "/snapshots", json={ "thread_id": "agent-thread", "messages": [], - "resume": {"interrupts": [{"id": confirm_call_id, "value": json.dumps({"accepted": True, "steps": []})}]}, + "resume": [ + { + "interruptId": confirm_call_id, + "status": "resolved", + "payload": json.dumps({"accepted": True, "steps": []}), + } + ], }, ) assert confirm_response.status_code == 200 @@ -1326,7 +2134,7 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): assert hydrate_response.status_code == 200 assert call_count == 1 events = _decode_sse_events(hydrate_response) - assert not events[-1].get("interrupt") + assert "outcome" not in events[-1] messages = _latest_messages_snapshot(hydrate_response) assert any( message.get("role") == "assistant" and message.get("content") == "Changes confirmed and applied successfully!" @@ -1575,30 +2383,198 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): assert first_response.status_code == 200 assert call_count == 1 first_finished = [event for event in _decode_sse_events(first_response) if event.get("type") == "RUN_FINISHED"] - interrupt_id = first_finished[-1]["interrupt"][0]["id"] + interrupt_id = _run_finished_interrupts(first_finished[-1])[0]["id"] resume_response = client.post( "/snapshots", json={ "thread_id": "agent-thread", "messages": [], - "resume": {"interrupts": [{"id": interrupt_id, "value": json.dumps({"accepted": True})}]}, + "resume": [ + { + "interruptId": interrupt_id, + "status": "resolved", + "payload": json.dumps({"accepted": True}), + } + ], }, ) assert resume_response.status_code == 200 assert call_count == 2 + assert "TEXT_MESSAGE_CONTENT" in [event.get("type") for event in _decode_sse_events(resume_response)] hydrate_response = client.post("/snapshots", json={"thread_id": "agent-thread", "messages": []}) assert hydrate_response.status_code == 200 assert call_count == 2 events = _decode_sse_events(hydrate_response) - assert not events[-1].get("interrupt") + assert "outcome" not in events[-1] contents = [message.get("content") for message in _latest_messages_snapshot(hydrate_response)] assert "Draft the plan" in contents assert "Resumed reply" in contents +async def test_agent_endpoint_approval_resume_seeds_provider_history_from_snapshot(): + """Snapshot-backed approval resume sends stored assistant tool call history before synthesized tool results.""" + executed_cities: list[str] = [] + + def get_weather(city: str) -> str: + executed_cities.append(city) + return f"Sunny in {city}" + + weather_tool = FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + approval_request = Content.from_function_approval_request( + id="call_get_weather", + function_call=Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ), + ) + agent = StubAgent( + updates=[ + AgentResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ) + ], + role="assistant", + ), + AgentResponseUpdate(contents=[approval_request], role="assistant"), + ], + default_options={"tools": [weather_tool]}, + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + AgentFrameworkAgent(agent=agent, require_confirmation=False), + path="/approval-snapshots", + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + + pause_response = client.post( + "/approval-snapshots", + json={ + "thread_id": "agent-approval-thread", + "messages": [{"role": "user", "content": "What is the weather?"}], + }, + ) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == "call_get_weather" + + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + resume_response = client.post( + "/approval-snapshots", + json={ + "thread_id": "agent-approval-thread", + "messages": [], + "resume": [{"interruptId": "call_get_weather", "status": "resolved", "payload": {"accepted": True}}], + }, + ) + + assert resume_response.status_code == 200 + assert executed_cities == ["Seattle"] + received = [ + ( + message.role, + content.type, + getattr(content, "call_id", None), + getattr(content, "name", None), + ) + for message in agent.messages_received + for content in message.contents + ] + assert ("user", "text", None, None) in received + assert ("assistant", "function_call", "call_get_weather", "get_weather") in received + assert ("tool", "function_result", "call_get_weather", None) in received + + +async def test_agent_endpoint_cancelled_approval_resume_clears_persisted_interrupt(): + """Cancelling an approval resume cancels the whole approval set and clears the stored interrupt prompt.""" + executed_cities: list[str] = [] + + def get_weather(city: str) -> str: + executed_cities.append(city) + return f"Sunny in {city}" + + weather_tool = FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + approval_request = Content.from_function_approval_request( + id="call_get_weather", + function_call=Content.from_function_call( + call_id="call_get_weather", + name="get_weather", + arguments={"city": "Seattle"}, + ), + ) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[approval_request], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + app = FastAPI() + store = InMemoryAGUIThreadSnapshotStore() + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval-snapshots", + snapshot_store=store, + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + + pause_response = client.post( + "/approval-snapshots", + json={ + "thread_id": "agent-approval-thread", + "messages": [{"role": "user", "content": "What is the weather?"}], + }, + ) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == "call_get_weather" + + cancel_response = client.post( + "/approval-snapshots", + json={ + "thread_id": "agent-approval-thread", + "messages": [], + "resume": [{"interruptId": "call_get_weather", "status": "cancelled"}], + }, + ) + assert cancel_response.status_code == 200 + cancel_events = _decode_sse_events(cancel_response) + assert [event for event in cancel_events if event.get("type") == "RUN_ERROR"][0][ + "code" + ] == "APPROVAL_RESUME_CANCELLED" + assert executed_cities == [] + assert not wrapped_agent._pending_approvals + + hydrate_response = client.post( + "/approval-snapshots", + json={"thread_id": "agent-approval-thread", "messages": []}, + ) + + assert hydrate_response.status_code == 200 + hydrate_events = _decode_sse_events(hydrate_response) + assert "outcome" not in hydrate_events[-1] + + async def test_agent_endpoint_ignores_forged_suffix_messages(streaming_chat_client_stub): """Client-forged assistant/tool messages after the stored prefix never become history.""" app = FastAPI() @@ -1721,6 +2697,67 @@ async def noop(message: Any, ctx: WorkflowContext[Any, Any]) -> None: assert snapshot.interrupt is None +async def test_workflow_endpoint_cancelled_resume_clears_persisted_interrupt(): + """A cancelled workflow resume consumes the pending request and clears the stored interrupt prompt.""" + app = FastAPI() + call_count = 0 + + @executor(id="requester") + async def requester(message: Any, ctx: WorkflowContext[Any, Any]) -> None: + nonlocal call_count + del message + call_count += 1 + await ctx.request_info( + {"message": "Approve workflow step", "options": ["Approve", "Reject"]}, + dict, + request_id="workflow-approval", + ) + + workflow = WorkflowBuilder(start_executor=requester).build() + store = InMemoryAGUIThreadSnapshotStore() + add_agent_framework_fastapi_endpoint( + app, + workflow, + path="/workflow-snapshots", + snapshot_store=store, + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + + pause_response = client.post( + "/workflow-snapshots", + json={"thread_id": "workflow-thread", "messages": [{"role": "user", "content": "Start workflow"}]}, + ) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == "workflow-approval" + assert call_count == 1 + + cancel_response = client.post( + "/workflow-snapshots", + json={ + "thread_id": "workflow-thread", + "messages": [], + "resume": [{"interruptId": "workflow-approval", "status": "cancelled"}], + }, + ) + assert cancel_response.status_code == 200 + cancel_events = _decode_sse_events(cancel_response) + assert [event for event in cancel_events if event.get("type") == "RUN_ERROR"][0][ + "code" + ] == "WORKFLOW_RESUME_CANCELLED" + + hydrate_response = client.post( + "/workflow-snapshots", + json={"thread_id": "workflow-thread", "messages": []}, + ) + + assert hydrate_response.status_code == 200 + hydrate_events = _decode_sse_events(hydrate_response) + assert "outcome" not in hydrate_events[-1] + assert call_count == 1 + + class _FailingSaveStore(InMemoryAGUIThreadSnapshotStore): """Store whose save always fails, simulating a transient backend outage.""" diff --git a/python/packages/ag-ui/tests/ag_ui/test_event_converters.py b/python/packages/ag-ui/tests/ag_ui/test_event_converters.py index 4b207c721cc..f4bc3ddc4f1 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_event_converters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_event_converters.py @@ -2,8 +2,11 @@ """Tests for AG-UI event converter.""" +import logging from typing import Any, cast +import pytest + from agent_framework_ag_ui._event_converters import AGUIEventConverter @@ -232,6 +235,83 @@ def test_run_finished_event_with_interrupt(self) -> None: assert update.additional_properties["interrupt"] == [{"id": "req_1", "value": {"question": "Continue?"}}] assert update.additional_properties["result"] == {"status": "paused"} + def test_run_finished_event_with_canonical_interrupt_outcome(self) -> None: + """RUN_FINISHED outcome.interrupts metadata is preserved in additional_properties.""" + converter = AGUIEventConverter() + converter.thread_id = "thread_123" + converter.run_id = "run_456" + + outcome = { + "type": "interrupt", + "interrupts": [ + { + "id": "req_1", + "reason": "input_required", + "message": "Choose a value", + "responseSchema": {"type": "string"}, + "metadata": {"agent_framework": {"request_type": "str"}}, + } + ], + } + event = { + "type": "RUN_FINISHED", + "threadId": "thread_123", + "runId": "run_456", + "outcome": outcome, + } + + update = converter.convert_event(event) + + assert update is not None + assert update.additional_properties is not None + assert update.additional_properties["outcome"] == outcome + assert update.additional_properties["interrupts"] == outcome["interrupts"] + + def test_run_finished_event_with_success_outcome_preserves_normal_completion(self) -> None: + """Non-interrupt RUN_FINISHED outcome metadata stays a normal stop update.""" + converter = AGUIEventConverter() + converter.thread_id = "thread_123" + converter.run_id = "run_456" + + event = { + "type": "RUN_FINISHED", + "threadId": "thread_123", + "runId": "run_456", + "outcome": {"type": "success"}, + } + + update = converter.convert_event(event) + + assert update is not None + assert update.finish_reason == "stop" + assert update.additional_properties is not None + assert update.additional_properties["outcome"] == {"type": "success"} + assert "interrupts" not in update.additional_properties + + def test_run_finished_event_with_non_dict_outcome_preserves_and_warns( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Malformed non-object outcome metadata is preserved but logged.""" + converter = AGUIEventConverter() + converter.thread_id = "thread_123" + converter.run_id = "run_456" + + event = { + "type": "RUN_FINISHED", + "threadId": "thread_123", + "runId": "run_456", + "outcome": "malformed", + } + + with caplog.at_level(logging.WARNING, logger="agent_framework_ag_ui._event_converters"): + update = converter.convert_event(event) + + assert update is not None + assert update.additional_properties is not None + assert update.additional_properties["outcome"] == "malformed" + assert "interrupts" not in update.additional_properties + assert "RUN_FINISHED outcome should be an object" in caplog.text + def test_run_error_event(self) -> None: """Test conversion of RUN_ERROR event.""" converter = AGUIEventConverter() diff --git a/python/packages/ag-ui/tests/ag_ui/test_http_service.py b/python/packages/ag-ui/tests/ag_ui/test_http_service.py index 49330be14d4..a758c2abaa5 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_http_service.py +++ b/python/packages/ag-ui/tests/ag_ui/test_http_service.py @@ -7,6 +7,7 @@ import httpx import pytest +from ag_ui.core import Interrupt, ResumeEntry from agent_framework_ag_ui._http_service import AGUIHttpService @@ -128,7 +129,9 @@ async def mock_aiter_lines(): state = {"user_context": {"name": "Alice"}} tools = [{"type": "function", "function": {"name": "test_tool"}}] available_interrupts = [{"id": "req_1", "type": "request_info"}] + expected_available_interrupts = [{"id": "req_1", "reason": "input_required"}] resume = {"interrupts": [{"id": "req_1", "value": "approved"}]} + expected_resume = [{"interruptId": "req_1", "status": "resolved", "payload": "approved"}] async for _ in service.post_run( thread_id="thread_123", @@ -146,8 +149,119 @@ async def mock_aiter_lines(): request_data = call_args.kwargs["json"] assert request_data["state"] == state assert request_data["tools"] == tools - assert request_data["availableInterrupts"] == available_interrupts - assert request_data["resume"] == resume + assert request_data["availableInterrupts"] == expected_available_interrupts + assert request_data["resume"] == expected_resume + + +async def test_post_run_serializes_typed_interrupts_and_resume_with_protocol_aliases(mock_http_client): + """Typed protocol interrupt and resume models are serialized to canonical wire fields.""" + + async def mock_aiter_lines(): + return + yield # Make it an async generator + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + async for _ in service.post_run( + thread_id="thread_123", + run_id="run_456", + messages=[], + available_interrupts=[ + Interrupt( + id="approval_1", + reason="tool_call", + tool_call_id="call_1", + response_schema={"type": "object"}, + ) + ], + resume=[ResumeEntry(interrupt_id="approval_1", status="resolved", payload={"approved": True})], + ): + pass + + request_data = mock_http_client.stream.call_args.kwargs["json"] + assert request_data["availableInterrupts"] == [ + { + "id": "approval_1", + "reason": "tool_call", + "toolCallId": "call_1", + "responseSchema": {"type": "object"}, + } + ] + assert request_data["resume"] == [ + {"interruptId": "approval_1", "status": "resolved", "payload": {"approved": True}} + ] + + +async def test_post_run_serializes_legacy_single_resume_mapping_as_canonical_list(mock_http_client): + """Legacy single-entry resume mappings are sent as canonical ResumeEntry arrays.""" + + async def mock_aiter_lines(): + return + yield # Make it an async generator + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + async for _ in service.post_run( + thread_id="thread_123", + run_id="run_456", + messages=[], + resume={"id": "approval_1", "value": {"approved": True}}, + ): + pass + + request_data = mock_http_client.stream.call_args.kwargs["json"] + assert request_data["resume"] == [ + {"interruptId": "approval_1", "status": "resolved", "payload": {"approved": True}} + ] + + +async def test_post_run_serializes_legacy_interrupt_without_type(mock_http_client): + """Legacy interrupt metadata without reason/type does not crash client serialization.""" + + async def mock_aiter_lines(): + return + yield # Make it an async generator + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.aiter_lines = mock_aiter_lines + + mock_stream_context = AsyncMock() + mock_stream_context.__aenter__.return_value = mock_response + mock_stream_context.__aexit__.return_value = None + mock_http_client.stream.return_value = mock_stream_context + + service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client) + + async for _ in service.post_run( + thread_id="thread_123", + run_id="run_456", + messages=[], + available_interrupts=[{"id": "req_1", "value": {"prompt": "Choose"}}], + ): + pass + + request_data = mock_http_client.stream.call_args.kwargs["json"] + assert request_data["availableInterrupts"][0]["id"] == "req_1" + assert request_data["availableInterrupts"][0]["reason"] == "input_required" async def test_post_run_http_error(mock_http_client): diff --git a/python/packages/ag-ui/tests/ag_ui/test_multi_turn.py b/python/packages/ag-ui/tests/ag_ui/test_multi_turn.py index c12c59a7e57..8d912c741ff 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_multi_turn.py +++ b/python/packages/ag-ui/tests/ag_ui/test_multi_turn.py @@ -198,8 +198,7 @@ async def test_approval_interrupt_resume_round_trip() -> None: stream1.assert_tool_calls_balanced() # Should have interrupt with function_approval_request - finished1 = stream1.last("RUN_FINISHED") - interrupt1 = finished1.model_dump().get("interrupt") + interrupt1 = stream1.run_finished_interrupts() assert interrupt1, "Expected interrupt in RUN_FINISHED" # Verify confirm_changes tool call was emitted @@ -259,8 +258,8 @@ async def test_approval_interrupt_resume_round_trip() -> None: # Turn 2 should NOT have interrupt (approval completed) finished2 = stream2.last("RUN_FINISHED") - interrupt2 = finished2.model_dump().get("interrupt") - assert not interrupt2, f"Expected no interrupt after approval, got {interrupt2}" + dumped2 = finished2.model_dump(by_alias=True, exclude_none=True) + assert "outcome" not in dumped2, f"Expected no interrupt after approval, got {dumped2.get('outcome')}" # ── Workflow interrupt/resume round-trip ── @@ -292,10 +291,9 @@ async def test_workflow_interrupt_resume_round_trip() -> None: stream1.assert_bookends() stream1.assert_no_run_error() - finished1 = stream1.last("RUN_FINISHED") - interrupt1 = finished1.model_dump().get("interrupt") + interrupt1 = stream1.run_finished_interrupts() assert interrupt1, "Expected flight interrupt" - assert interrupt1[0]["value"]["agent"] == "flights" + assert stream1.interrupt_metadata_value(interrupt1[0])["agent"] == "flights" # Turn 2: resume with flight selection events2 = [ @@ -329,7 +327,6 @@ async def test_workflow_interrupt_resume_round_trip() -> None: stream2.assert_no_run_error() # Should now have hotel interrupt - finished2 = stream2.last("RUN_FINISHED") - interrupt2 = finished2.model_dump().get("interrupt") + interrupt2 = stream2.run_finished_interrupts() assert interrupt2, "Expected hotel interrupt" - assert interrupt2[0]["value"]["agent"] == "hotels" + assert stream2.interrupt_metadata_value(interrupt2[0])["agent"] == "hotels" diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 11fad20be35..be26196d03a 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -20,14 +20,22 @@ ) from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream from agent_framework.exceptions import AgentInvalidResponseException +from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] +from agent_framework_ag_ui._agent import AgentConfig from agent_framework_ag_ui._agent_run import ( + PendingApprovalEntry, + PendingApprovalKey, _build_safe_metadata, + _canonical_approval_resume_messages, _create_state_context_message, _inject_state_context, + _make_pending_approval_entry, _normalize_response_stream, + _pending_approval_key, _resume_to_tool_messages, _should_suppress_intermediate_snapshot, + run_agent_stream, ) from agent_framework_ag_ui._run_common import ( FlowState, @@ -544,7 +552,7 @@ def test_emit_content_usage_emits_custom_usage_event(): def test_emit_approval_request_populates_interrupt_metadata(): - """Approval requests should populate FlowState interrupts for RUN_FINISHED metadata.""" + """Approval requests should populate canonical interrupt metadata for RUN_FINISHED.""" flow = FlowState(message_id="msg-1") function_call = Content.from_function_call(call_id="call_123", name="write_doc", arguments={"content": "x"}) approval_content = Content.from_function_approval_request(id="approval_1", function_call=function_call) @@ -554,7 +562,18 @@ def test_emit_approval_request_populates_interrupt_metadata(): assert flow.waiting_for_approval is True assert len(flow.interrupts) == 1 assert flow.interrupts[0]["id"] == "call_123" - assert flow.interrupts[0]["value"]["type"] == "function_approval_request" + assert flow.interrupts[0]["reason"] == "tool_call" + assert flow.interrupts[0]["toolCallId"] == "call_123" + assert flow.interrupts[0]["message"] == "Approve running write_doc?" + assert flow.interrupts[0]["responseSchema"]["required"] == ["accepted"] + assert flow.interrupts[0]["responseSchema"]["properties"]["accepted"]["type"] == "boolean" + assert flow.interrupts[0]["responseSchema"]["properties"]["content"]["type"] == "string" + assert flow.interrupts[0]["metadata"]["agent_framework"]["type"] == "function_approval_request" + assert flow.interrupts[0]["metadata"]["agent_framework"]["function_call"] == { + "call_id": "call_123", + "name": "write_doc", + "arguments": {"content": "x"}, + } def test_emit_approval_request_accumulates_multiple_interrupts(): @@ -578,6 +597,58 @@ def test_emit_approval_request_accumulates_multiple_interrupts(): assert interrupt_ids == {"call_1", "call_2", "call_3"} +async def test_predictive_confirmation_run_finished_interrupt_links_tool_call(): + """Tool-bound confirmation pauses should advertise canonical interrupt routing.""" + agent = StubAgent( + updates=[ + AgentResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_write_doc", + name="write_doc", + arguments={"content": "Draft"}, + ) + ], + role="assistant", + ) + ] + ) + config = AgentConfig( + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + require_confirmation=True, + ) + + events = [ + event + async for event in run_agent_stream( + { + "run_id": "run-confirm", + "thread_id": "thread-confirm", + "messages": [{"role": "user", "content": "Write a draft"}], + }, + agent, + config, + ) + ] + run_finished = [event for event in events if getattr(event, "type", None) == "RUN_FINISHED"] + assert len(run_finished) == 1 + dumped = run_finished[0].model_dump(by_alias=True, exclude_none=True) + + assert "interrupt" not in dumped + interrupt = dumped["outcome"]["interrupts"][0] + assert interrupt["reason"] == "tool_call" + assert interrupt["toolCallId"] == "call_write_doc" + assert interrupt["message"] == "Approve the proposed changes from write_doc?" + assert interrupt["responseSchema"]["properties"]["accepted"]["type"] == "boolean" + assert interrupt["responseSchema"]["properties"]["steps"]["type"] == "array" + assert interrupt["metadata"]["agent_framework"]["confirmation_tool_call_id"] == interrupt["id"] + assert interrupt["metadata"]["agent_framework"]["function_call"] == { + "call_id": "call_write_doc", + "name": "write_doc", + "arguments": {"content": "Draft"}, + } + + def test_resume_to_tool_messages_from_interrupts_payload(): """Resume payload interrupt responses map to tool messages.""" resume = { @@ -595,6 +666,72 @@ def test_resume_to_tool_messages_from_interrupts_payload(): assert messages[1]["content"] == "plain value" +def test_resume_to_tool_messages_skips_cancelled_entries(): + """Cancelled generic resume entries must not become fabricated tool messages.""" + resume = [ + {"interruptId": "req_1", "status": "cancelled"}, + {"interruptId": "req_2", "status": "resolved", "payload": "plain value"}, + ] + + messages = _resume_to_tool_messages(resume) + + assert messages == [{"role": "tool", "toolCallId": "req_2", "content": "plain value"}] + + +def test_canonical_approval_resume_does_not_mutate_arguments_until_batch_validates(): + """Edited approval arguments are committed only after every resume entry validates.""" + pending_entry = _make_pending_approval_entry( + "get_weather", + '{"city":"Seattle"}', + request_id="call_a", + interrupt_id="call_a", + ) + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] = { + _pending_approval_key("thread-weather", "call_a"): pending_entry, + _pending_approval_key("thread-weather", "call_b"): _make_pending_approval_entry( + "get_weather", + '{"city":"Portland"}', + request_id="call_b", + interrupt_id="call_b", + ), + } + + messages, handled_ids, cancelled_ids, error = _canonical_approval_resume_messages( + [ + {"interruptId": "call_a", "status": "resolved", "payload": {"accepted": True, "city": "Portland"}}, + {"interruptId": "call_b", "status": "resolved", "payload": "not an object"}, + ], + pending_approvals, + "thread-weather", + ) + + assert messages == [] + assert handled_ids == {"call_a", "call_b"} + assert cancelled_ids == set() + assert error is not None + assert error.code == "APPROVAL_RESUME_INVALID" + assert pending_entry["arguments"] == '{"city":"Seattle"}' + + +def test_pending_approval_registry_scans_exact_thread_keys_with_colons(): + """A thread id that prefixes another thread id must not inherit its pending approval contract.""" + pending_approvals: dict[PendingApprovalKey, PendingApprovalEntry] = { + _pending_approval_key("tenant:thread", "call_1"): _make_pending_approval_entry( + "get_weather", + '{"city":"Seattle"}', + request_id="call_1", + interrupt_id="call_1", + ) + } + + _, _, _, unrelated_error = _canonical_approval_resume_messages(None, pending_approvals, "tenant") + _, _, _, owning_error = _canonical_approval_resume_messages(None, pending_approvals, "tenant:thread") + + assert unrelated_error is None + assert owning_error is not None + assert owning_error.code == "APPROVAL_RESUME_REQUIRED" + + def test_extract_resume_payload_prefers_top_level_resume(): """Top-level resume should take precedence over forwarded props.""" payload = { @@ -620,13 +757,16 @@ def test_extract_resume_payload_reads_forwarded_command_resume(): def test_build_run_finished_event_with_interrupt(): - """RUN_FINISHED helper should preserve interrupt payloads.""" + """RUN_FINISHED helper should emit canonical interrupt outcomes.""" event = _build_run_finished_event("run-1", "thread-1", interrupts=[{"id": "req_1", "value": {"x": 1}}]) - dumped = event.model_dump() + dumped = event.model_dump(by_alias=True, exclude_none=True) - assert dumped["run_id"] == "run-1" - assert dumped["thread_id"] == "thread-1" - assert dumped["interrupt"] == [{"id": "req_1", "value": {"x": 1}}] + assert dumped["runId"] == "run-1" + assert dumped["threadId"] == "thread-1" + assert "interrupt" not in dumped + assert dumped["outcome"]["type"] == "interrupt" + assert dumped["outcome"]["interrupts"][0]["id"] == "req_1" + assert dumped["outcome"]["interrupts"][0]["metadata"]["agent_framework"]["value"] == {"x": 1} def test_extract_approved_state_updates_no_handler(): @@ -981,12 +1121,17 @@ async def test_run_agent_stream_accumulates_multiple_confirm_interrupts(): ] assert finished_events, f"Expected RUN_FINISHED event. Types: {[getattr(e, 'type', None) for e in events]}" finished = finished_events[-1] - interrupt = getattr(finished, "interrupt", None) - assert interrupt is not None, "Expected interrupt metadata in RUN_FINISHED" + dumped = finished.model_dump(by_alias=True, exclude_none=True) + assert "interrupt" not in dumped + outcome = dumped.get("outcome") + assert isinstance(outcome, dict) + assert outcome.get("type") == "interrupt" + interrupt = outcome.get("interrupts") + assert isinstance(interrupt, list), "Expected interrupt metadata in RUN_FINISHED.outcome" assert len(interrupt) == 2, f"Expected 2 interrupts (one per tool), got {len(interrupt)}" # Verify both tool calls are represented in interrupt metadata - interrupt_tool_names = {i["value"]["function_call"]["name"] for i in interrupt} + interrupt_tool_names = {i["metadata"]["agent_framework"]["value"]["function_call"]["name"] for i in interrupt} assert interrupt_tool_names == {"generate_tasks", "generate_notes"} diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 7571f87abb1..80c4dcd89c6 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -2,6 +2,8 @@ """Tests for _run_common.py edge cases.""" +import logging + import pytest from ag_ui.core import EventType from agent_framework import Content @@ -10,11 +12,14 @@ from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler from agent_framework_ag_ui._run_common import ( FlowState, + _build_run_finished_event, _emit_mcp_tool_result, _emit_tool_result, _extract_resume_payload, _extract_tool_result_state, _normalize_resume_interrupts, + _reconstruct_messages_from_thread_snapshot, + _strict_resume_entries, ) from agent_framework_ag_ui._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY @@ -75,6 +80,30 @@ def test_tool_call_id_key_used_as_id(self): result = _normalize_resume_interrupts([{"toolCallId": "tc1", "value": "done"}]) assert result == [{"id": "tc1", "value": "done"}] + def test_canonical_resume_entry_uses_interrupt_id_and_payload(self): + """Canonical ResumeEntry dictionaries preserve status and map payload to legacy runner values.""" + result = _normalize_resume_interrupts( + [{"interrupt_id": "req_1", "status": "resolved", "payload": {"approved": True}}] + ) + assert result == [{"id": "req_1", "value": {"approved": True}, "status": "resolved"}] + + +class TestStrictResumeEntries: + """Tests for strict canonical resume-entry parsing.""" + + def test_tool_call_id_key_used_as_interrupt_id(self) -> None: + """toolCallId is accepted as a legacy identifier alias and excluded from payload.""" + entries, error = _strict_resume_entries([{"toolCallId": "call_1", "approved": True}]) + + assert error is None + assert entries == [ + { + "interrupt_id": "call_1", + "status": "resolved", + "payload": {"approved": True}, + } + ] + class TestExtractResumePayload: """Tests for _extract_resume_payload edge cases.""" @@ -105,6 +134,76 @@ def test_forwarded_props_camelcase(self): assert result == "camel" +class TestRunFinishedEvent: + """Tests for externally visible RUN_FINISHED event shape.""" + + def test_build_run_finished_event_with_interrupt_outcome(self) -> None: + """Interrupted RUN_FINISHED uses canonical outcome.interrupts without a top-level interrupt field.""" + event = _build_run_finished_event("run-1", "thread-1", interrupts=[{"id": "req_1", "value": {"x": 1}}]) + dumped = event.model_dump(by_alias=True, exclude_none=True) + + assert dumped["runId"] == "run-1" + assert dumped["threadId"] == "thread-1" + assert "interrupt" not in dumped + assert dumped["outcome"] == { + "type": "interrupt", + "interrupts": [ + { + "id": "req_1", + "reason": "input_required", + "metadata": {"agent_framework": {"value": {"x": 1}}}, + } + ], + } + + def test_build_run_finished_event_logs_when_interrupts_all_drop(self, caplog: pytest.LogCaptureFixture) -> None: + """Interrupted input that canonicalizes to no interrupts is logged.""" + with caplog.at_level(logging.WARNING, logger="agent_framework_ag_ui._run_common"): + event = _build_run_finished_event( + "run-1", + "thread-1", + interrupts=[{"reason": "input_required", "message": "Need input"}], + ) + + dumped = event.model_dump(by_alias=True, exclude_none=True) + assert "outcome" not in dumped + assert "1 interrupt(s) present but none carried an id/interruptId" in caplog.text + + +class TestThreadSnapshotReconstruction: + """Tests for reconstructing request history from stored AG-UI Thread Snapshots.""" + + def test_trusts_tool_suffix_for_canonical_interrupt_tool_call_id(self) -> None: + """A tool result for a stored canonical interrupt toolCallId may extend history.""" + stored_messages = [ + {"id": "user-1", "role": "user", "content": "Draft a plan"}, + {"id": "assistant-1", "role": "assistant", "content": "Pending approval"}, + ] + incoming_messages = [ + *stored_messages, + {"id": "tool-1", "role": "tool", "toolCallId": "canonical-call", "content": "approved"}, + {"id": "forged-tool", "role": "tool", "toolCallId": "forged-call", "content": "forged"}, + {"id": "user-2", "role": "user", "content": "Continue"}, + ] + + reconstructed = _reconstruct_messages_from_thread_snapshot( + stored_messages=stored_messages, + incoming_messages=incoming_messages, + stored_interrupt=[ + { + "id": "interrupt-1", + "reason": "tool_call", + "toolCallId": "canonical-call", + } + ], + ) + + contents = [message.get("content") for message in reconstructed] + assert "approved" in contents + assert "Continue" in contents + assert "forged" not in contents + + class TestEmitToolResult: """Tests for _emit_tool_result edge cases.""" diff --git a/python/packages/ag-ui/tests/ag_ui/test_subgraphs_example_agent.py b/python/packages/ag-ui/tests/ag_ui/test_subgraphs_example_agent.py index fb239f3b4ba..8970885e990 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_subgraphs_example_agent.py +++ b/python/packages/ag-ui/tests/ag_ui/test_subgraphs_example_agent.py @@ -14,6 +14,27 @@ async def _run(agent: Any, payload: dict[str, Any]) -> list[Any]: return [event async for event in agent.run(payload)] +def _interrupts_from_finished(event: Any) -> list[dict[str, Any]]: + dumped = event.model_dump(by_alias=True, exclude_none=True) + assert "interrupt" not in dumped + outcome = dumped.get("outcome") + assert isinstance(outcome, dict) + assert outcome.get("type") == "interrupt" + interrupts = outcome.get("interrupts") + assert isinstance(interrupts, list) + return interrupts + + +def _interrupt_value(interrupt: dict[str, Any]) -> dict[str, Any]: + metadata = interrupt.get("metadata") + assert isinstance(metadata, dict) + agent_framework_metadata = metadata.get("agent_framework") + assert isinstance(agent_framework_metadata, dict) + value = agent_framework_metadata.get("value") + assert isinstance(value, dict) + return value + + async def test_subgraphs_example_initial_run_emits_flight_interrupt() -> None: """Initial run should publish flight options and pause with an interrupt.""" agent = subgraphs_agent() @@ -43,12 +64,12 @@ async def test_subgraphs_example_initial_run_emits_flight_interrupt() -> None: assert "flights_agent" in finished_steps finished = [event for event in events if event.type == "RUN_FINISHED"][0] - interrupt_payload = finished.model_dump().get("interrupt") - assert isinstance(interrupt_payload, list) + interrupt_payload = _interrupts_from_finished(finished) assert interrupt_payload - assert interrupt_payload[0]["value"]["agent"] == "flights" - assert len(interrupt_payload[0]["value"]["options"]) == 2 - assert interrupt_payload[0]["value"]["options"][0]["airline"] == "KLM" + interrupt_value = _interrupt_value(interrupt_payload[0]) + assert interrupt_value["agent"] == "flights" + assert len(interrupt_value["options"]) == 2 + assert interrupt_value["options"][0]["airline"] == "KLM" custom_event_names = [event.name for event in events if event.type == "CUSTOM"] assert "WorkflowInterruptEvent" in custom_event_names @@ -66,7 +87,8 @@ async def test_subgraphs_example_resume_flow_reaches_completion() -> None: "messages": [{"role": "user", "content": "I want to visit San Francisco from Amsterdam"}], }, ) - first_interrupt = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump()["interrupt"][0] + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + first_interrupt = _interrupts_from_finished(first_finished)[0] second_events = await _run( agent, @@ -91,10 +113,9 @@ async def test_subgraphs_example_resume_flow_reaches_completion() -> None: }, }, ) - second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0].model_dump() - second_interrupt = second_finished.get("interrupt") - assert isinstance(second_interrupt, list) - assert second_interrupt[0]["value"]["agent"] == "hotels" + second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0] + second_interrupt = _interrupts_from_finished(second_finished) + assert _interrupt_value(second_interrupt[0])["agent"] == "hotels" third_events = await _run( agent, @@ -133,7 +154,7 @@ async def test_subgraphs_example_resume_flow_reaches_completion() -> None: async def test_subgraphs_example_requires_structured_resume_for_selection() -> None: - """Agent should re-issue interrupts when user sends plain text instead of resume payload.""" + """Agent should fail when user sends plain text instead of a resume payload.""" agent = subgraphs_agent() thread_id = "thread-subgraphs-text" @@ -145,9 +166,9 @@ async def test_subgraphs_example_requires_structured_resume_for_selection() -> N "messages": [{"role": "user", "content": "Plan a trip for me"}], }, ) - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - assert isinstance(first_finished.get("interrupt"), list) - assert first_finished["interrupt"][0]["value"]["agent"] == "flights" + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + first_interrupt = _interrupts_from_finished(first_finished) + assert _interrupt_value(first_interrupt[0])["agent"] == "flights" second_events = await _run( agent, @@ -157,10 +178,9 @@ async def test_subgraphs_example_requires_structured_resume_for_selection() -> N "messages": [{"role": "user", "content": "Let's do the United flight"}], }, ) - second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0].model_dump() - assert isinstance(second_finished.get("interrupt"), list) - assert second_finished["interrupt"][0]["value"]["agent"] == "flights" - assert "TOOL_CALL_START" in [event.type for event in second_events] + run_errors = [event for event in second_events if event.type == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0].code == "WORKFLOW_RESUME_REQUIRED" assert "TEXT_MESSAGE_CONTENT" not in [event.type for event in second_events] third_events = await _run( @@ -171,7 +191,7 @@ async def test_subgraphs_example_requires_structured_resume_for_selection() -> N "resume": { "interrupts": [ { - "id": second_finished["interrupt"][0]["id"], + "id": first_interrupt[0]["id"], "value": json.dumps( { "airline": "United", @@ -186,16 +206,16 @@ async def test_subgraphs_example_requires_structured_resume_for_selection() -> N }, }, ) - third_finished = [event for event in third_events if event.type == "RUN_FINISHED"][0].model_dump() - assert isinstance(third_finished.get("interrupt"), list) - assert third_finished["interrupt"][0]["value"]["agent"] == "hotels" + third_finished = [event for event in third_events if event.type == "RUN_FINISHED"][0] + third_interrupt = _interrupts_from_finished(third_finished) + assert _interrupt_value(third_interrupt[0])["agent"] == "hotels" third_snapshots = [event.snapshot for event in third_events if event.type == "STATE_SNAPSHOT"] assert third_snapshots[-1]["itinerary"]["flight"]["airline"] == "United" async def test_subgraphs_example_forwarded_command_resume_reaches_hotels_interrupt() -> None: - """CopilotKit-style forwarded command.resume should continue workflow interrupts.""" + """Forwarded command.resume should continue workflow interrupts with canonical resume entries.""" agent = subgraphs_agent() thread_id = "thread-subgraphs-forwarded-resume" @@ -207,7 +227,8 @@ async def test_subgraphs_example_forwarded_command_resume_reaches_hotels_interru "messages": [{"role": "user", "content": "Plan my trip"}], }, ) - first_interrupt = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump()["interrupt"][0] + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + first_interrupt = _interrupts_from_finished(first_finished)[0] second_events = await _run( agent, @@ -217,22 +238,25 @@ async def test_subgraphs_example_forwarded_command_resume_reaches_hotels_interru "messages": [], "forwarded_props": { "command": { - "resume": json.dumps( + "resume": [ { - "airline": "KLM", - "departure": "Amsterdam (AMS)", - "arrival": "San Francisco (SFO)", - "price": "$650", - "duration": "11h 30m", + "interruptId": first_interrupt["id"], + "status": "resolved", + "payload": { + "airline": "KLM", + "departure": "Amsterdam (AMS)", + "arrival": "San Francisco (SFO)", + "price": "$650", + "duration": "11h 30m", + }, } - ) + ] } }, }, ) - second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0].model_dump() - second_interrupt = second_finished.get("interrupt") - assert isinstance(second_interrupt, list) - assert second_interrupt[0]["value"]["agent"] == "hotels" + second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0] + second_interrupt = _interrupts_from_finished(second_finished) + assert _interrupt_value(second_interrupt[0])["agent"] == "hotels" assert second_interrupt[0]["id"] != first_interrupt["id"] diff --git a/python/packages/ag-ui/tests/ag_ui/test_types.py b/python/packages/ag-ui/tests/ag_ui/test_types.py index 65615a41ea5..8276104e5ed 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_types.py +++ b/python/packages/ag-ui/tests/ag_ui/test_types.py @@ -2,6 +2,9 @@ """Tests for type definitions in _types.py.""" +import pytest +from pydantic import ValidationError + from agent_framework_ag_ui._types import AgentState, AGUIRequest, PredictStateConfig, RunMetadata @@ -248,15 +251,77 @@ def test_agui_request_model_dump_includes_all_set_fields(self) -> None: assert dumped["parent_run_id"] == "parent-456" def test_agui_request_available_interrupts_alias_round_trip(self) -> None: - """availableInterrupts should deserialize, while dumps remain snake_case.""" + """availableInterrupts should deserialize to canonical Interrupt models.""" request = AGUIRequest.model_validate( { "messages": [{"role": "user", "content": "Hello"}], - "availableInterrupts": [{"id": "req_1", "value": {"choice": "A"}}], + "availableInterrupts": [{"id": "req_1", "reason": "input_required", "message": "Choose"}], } ) - assert request.available_interrupts == [{"id": "req_1", "value": {"choice": "A"}}] + assert request.available_interrupts is not None + assert request.available_interrupts[0].id == "req_1" + assert request.available_interrupts[0].reason == "input_required" dumped = request.model_dump(exclude_none=True) - assert dumped["available_interrupts"] == [{"id": "req_1", "value": {"choice": "A"}}] + assert dumped["available_interrupts"] == [{"id": "req_1", "reason": "input_required", "message": "Choose"}] assert "availableInterrupts" not in dumped + + def test_agui_request_resume_accepts_canonical_entries(self) -> None: + """resume should preserve AG-UI resume arrays at the HTTP trust boundary.""" + request = AGUIRequest.model_validate( + { + "messages": [{"role": "user", "content": "Hello"}], + "resume": [{"interruptId": "req_1", "status": "resolved", "payload": {"approved": True}}], + } + ) + + assert request.resume is not None + assert request.resume[0].interrupt_id == "req_1" + assert request.resume[0].status == "resolved" + assert request.resume[0].payload == {"approved": True} + + def test_agui_request_resume_schema_advertises_canonical_entries(self) -> None: + """resume should advertise the canonical ResumeEntry array shape in JSON schema.""" + resume_schema = AGUIRequest.model_json_schema()["properties"]["resume"] + array_schema = next((schema for schema in resume_schema["anyOf"] if schema.get("type") == "array"), None) + + assert array_schema is not None + assert array_schema["items"] == {"$ref": "#/$defs/ResumeEntry"} + + def test_agui_request_resume_accepts_legacy_object_shapes(self) -> None: + """resume coerces supported legacy containers to canonical ResumeEntry models.""" + request = AGUIRequest.model_validate( + { + "messages": [{"role": "user", "content": "Hello"}], + "resume": {"interrupts": [{"id": "req_1", "value": {"approved": True}}]}, + } + ) + + assert request.resume is not None + assert request.resume[0].interrupt_id == "req_1" + assert request.resume[0].status == "resolved" + assert request.resume[0].payload == {"approved": True} + + def test_agui_request_resume_accepts_legacy_single_entry_mapping(self) -> None: + """resume coerces a supported single legacy entry object to a one-entry canonical list.""" + request = AGUIRequest.model_validate( + { + "messages": [{"role": "user", "content": "Hello"}], + "resume": {"toolCallId": "call_1", "approved": True}, + } + ) + + assert request.resume is not None + assert request.resume[0].interrupt_id == "call_1" + assert request.resume[0].status == "resolved" + assert request.resume[0].payload == {"approved": True} + + def test_agui_request_resume_rejects_malformed_shape(self) -> None: + """resume rejects malformed inputs at request validation once the contract shape is advertised.""" + with pytest.raises(ValidationError): + AGUIRequest.model_validate( + { + "messages": [{"role": "user", "content": "Hello"}], + "resume": {"unexpected": "shape"}, + } + ) diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py index 858d10370f0..6fa23ac2cc2 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py @@ -16,6 +16,17 @@ async def _run(agent: AgentFrameworkWorkflow, payload: dict[str, Any]) -> list[A return [event async for event in agent.run(payload)] +def _interrupts_from_finished(event: Any) -> list[dict[str, Any]]: + dumped = event.model_dump(by_alias=True, exclude_none=True) + assert "interrupt" not in dumped + outcome = dumped.get("outcome") + assert isinstance(outcome, dict) + assert outcome.get("type") == "interrupt" + interrupts = outcome.get("interrupts") + assert isinstance(interrupts, list) + return cast(list[dict[str, Any]], interrupts) + + async def test_workflow_wrapper_rejects_workflow_and_factory_at_once() -> None: """Workflow wrapper should reject ambiguous workflow source configuration.""" @@ -52,9 +63,8 @@ def workflow_factory(thread_id: str) -> Workflow: "messages": [{"role": "user", "content": "start"}], }, ) - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - first_interrupt = first_finished.get("interrupt") - assert isinstance(first_interrupt, list) + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + first_interrupt = _interrupts_from_finished(first_finished) assert first_interrupt[0]["id"] == "choice" assert factory_calls["thread-a"] == 1 @@ -68,8 +78,10 @@ def workflow_factory(thread_id: str) -> Workflow: ) second_types = [event.type for event in second_events] assert "RUN_ERROR" not in second_types - second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0].model_dump() - assert "interrupt" not in second_finished + second_finished = [event for event in second_events if event.type == "RUN_FINISHED"][0].model_dump( + by_alias=True, exclude_none=True + ) + assert "outcome" not in second_finished assert factory_calls["thread-a"] == 1 third_events = await _run( @@ -79,9 +91,8 @@ def workflow_factory(thread_id: str) -> Workflow: "messages": [{"role": "user", "content": "start"}], }, ) - third_finished = [event for event in third_events if event.type == "RUN_FINISHED"][0].model_dump() - third_interrupt = third_finished.get("interrupt") - assert isinstance(third_interrupt, list) + third_finished = [event for event in third_events if event.type == "RUN_FINISHED"][0] + third_interrupt = _interrupts_from_finished(third_finished) assert third_interrupt[0]["id"] == "choice" assert factory_calls["thread-b"] == 1 diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 7c25d58ccd3..aa1c05b3936 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -29,6 +29,7 @@ _coerce_message_content, _coerce_response_for_request, _coerce_responses_for_pending_requests, + _coerce_responses_for_pending_requests_strict, _custom_event_value, _details_code, _details_message, @@ -54,6 +55,34 @@ def __init__(self, progress: int) -> None: super().__init__(cast(Any, "custom_progress"), data={"progress": progress}) +def _run_finished_dump(event: Any) -> dict[str, Any]: + """Serialize a RUN_FINISHED event as AG-UI wire JSON.""" + return cast(dict[str, Any], event.model_dump(by_alias=True, exclude_none=True)) + + +def _interrupts_from_run_finished(event: Any) -> list[dict[str, Any]]: + """Return canonical interrupts from a RUN_FINISHED event.""" + dumped = _run_finished_dump(event) + assert "interrupt" not in dumped + outcome = dumped.get("outcome") + assert isinstance(outcome, dict) + assert outcome.get("type") == "interrupt" + interrupts = outcome.get("interrupts") + assert isinstance(interrupts, list) + return cast(list[dict[str, Any]], interrupts) + + +def _interrupt_metadata_value(interrupt: dict[str, Any]) -> dict[str, Any]: + """Return Agent Framework legacy interruption details from canonical metadata.""" + metadata = interrupt.get("metadata") + assert isinstance(metadata, dict) + agent_framework_metadata = metadata.get("agent_framework") + assert isinstance(agent_framework_metadata, dict) + value = agent_framework_metadata.get("value") + assert isinstance(value, dict) + return cast(dict[str, Any], value) + + async def test_workflow_run_maps_custom_and_text_events(): """Custom workflow events and yielded text are mapped to AG-UI events.""" @@ -95,8 +124,7 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: run_finished_events = [event for event in first_run_events if event.type == "RUN_FINISHED"] assert len(run_finished_events) == 1 - interrupt_payload = run_finished_events[0].model_dump().get("interrupt") - assert isinstance(interrupt_payload, list) + interrupt_payload = _interrupts_from_run_finished(run_finished_events[0]) assert len(interrupt_payload) == 1 request_id = str(interrupt_payload[0]["id"]) @@ -140,7 +168,7 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: async def test_workflow_run_request_info_interrupt_uses_raw_dict_value(): - """Dict request payloads should be surfaced directly in RUN_FINISHED.interrupt.value.""" + """Dict request payloads should be preserved in canonical interrupt metadata.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -158,16 +186,18 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: workflow = WorkflowBuilder(start_executor=requester).build() events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)] - run_finished = [event for event in events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = run_finished.get("interrupt") - assert isinstance(interrupt_payload, list) + run_finished = [event for event in events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(run_finished) + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) assert interrupt_payload[0]["id"] == "flights-choice" - assert interrupt_payload[0]["value"]["agent"] == "flights" - assert interrupt_payload[0]["value"]["message"] == "Choose a flight" + assert interrupt_payload[0]["reason"] == "input_required" + assert interrupt_payload[0]["message"] == "Choose a flight" + assert interrupt_value["agent"] == "flights" + assert interrupt_value["message"] == "Choose a flight" async def test_workflow_run_resume_from_forwarded_command_payload() -> None: - """forwarded_props.command.resume should resume a pending dict request.""" + """forwarded_props.command.resume should support canonical resume entries.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -183,7 +213,15 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: { "messages": [], "forwarded_props": { - "command": {"resume": json.dumps({"airline": "KLM", "departure": "AMS", "arrival": "SFO"})} + "command": { + "resume": [ + { + "interruptId": "flights-choice", + "status": "resolved", + "payload": {"airline": "KLM", "departure": "AMS", "arrival": "SFO"}, + } + ] + } }, }, workflow, @@ -196,8 +234,8 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: assert "interrupt" not in finished -async def test_workflow_run_structured_user_json_resumes_single_pending_request() -> None: - """A JSON user reply should resume a single pending dict request without heuristics.""" +async def test_workflow_run_structured_user_json_with_pending_request_emits_run_error() -> None: + """A pending request requires canonical resume entries, not heuristic JSON user replies.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -218,9 +256,9 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: ] resumed_types = [event.type for event in resumed_events] - assert "RUN_ERROR" not in resumed_types - finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump() - assert "interrupt" not in finished + assert resumed_types == ["RUN_STARTED", "RUN_ERROR"] + run_error = [event for event in resumed_events if event.type == "RUN_ERROR"][0] + assert run_error.code == "WORKFLOW_RESUME_REQUIRED" async def test_workflow_run_resume_content_response_from_json_payload() -> None: @@ -251,9 +289,9 @@ async def handle_approval(self, original_request: Content, response: Content, ct first_events = [ event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow) ] - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt")) - interrupt_value = cast(dict[str, Any], interrupt_payload[0]["value"]) + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(first_finished) + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) resumed_events: list[Any] = [ event @@ -415,18 +453,13 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: ] follow_up_types = [event.type for event in follow_up_events] - assert "RUN_ERROR" not in follow_up_types - assert "TOOL_CALL_START" in follow_up_types - - run_finished = [event for event in follow_up_events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = run_finished.get("interrupt") - assert isinstance(interrupt_payload, list) - assert interrupt_payload[0]["id"] == "flights-choice" - assert interrupt_payload[0]["value"]["agent"] == "flights" + assert follow_up_types == ["RUN_STARTED", "RUN_ERROR"] + run_error = [event for event in follow_up_events if event.type == "RUN_ERROR"][0] + assert getattr(run_error, "code") == "WORKFLOW_RESUME_REQUIRED" -async def test_workflow_run_empty_turn_with_pending_request_preserves_interrupts(): - """An empty turn should still return pending workflow interrupts without errors.""" +async def test_workflow_run_empty_turn_with_pending_request_emits_run_error(): + """An empty turn with pending workflow interrupts must provide resume entries.""" @executor(id="requester") async def requester(message: Any, ctx: WorkflowContext) -> None: @@ -438,14 +471,9 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: events = [event async for event in run_workflow_stream({"messages": []}, workflow)] types = [event.type for event in events] - assert types[0] == "RUN_STARTED" - assert "RUN_FINISHED" in types - assert "RUN_ERROR" not in types - - finished = [event for event in events if event.type == "RUN_FINISHED"][0].model_dump() - interrupts = finished.get("interrupt") - assert isinstance(interrupts, list) - assert interrupts[0]["id"] == "pick-one" + assert types == ["RUN_STARTED", "RUN_ERROR"] + run_error = [event for event in events if event.type == "RUN_ERROR"][0] + assert getattr(run_error, "code") == "WORKFLOW_RESUME_REQUIRED" async def test_workflow_run_agent_response_output_uses_latest_assistant_message_only() -> None: @@ -739,13 +767,22 @@ def test_dict_data_used_directly(self): """Dict data is used as interrupt value.""" event = SimpleNamespace(request_id="r1", data={"key": "val"}) result = _interrupt_entry_for_request_event(event) - assert result == {"id": "r1", "value": {"key": "val"}} + assert result is not None + assert result["id"] == "r1" + assert result["reason"] == "input_required" + assert result["value"] == {"key": "val"} + assert result["metadata"]["agent_framework"]["type"] == "workflow_request_info" + assert result["metadata"]["agent_framework"]["request_id"] == "r1" def test_non_dict_data_wrapped(self): """Non-dict data is wrapped in {data: ...}.""" event = SimpleNamespace(request_id="r1", data="text") result = _interrupt_entry_for_request_event(event) - assert result == {"id": "r1", "value": {"data": "text"}} + assert result is not None + assert result["id"] == "r1" + assert result["reason"] == "input_required" + assert result["value"] == {"data": "text"} + assert result["metadata"]["agent_framework"]["value"] == {"data": "text"} class TestRequestPayloadFromRequestEvent: @@ -972,6 +1009,23 @@ def test_empty_responses(self): assert result == {} +class TestCoerceResponsesForPendingRequestsStrict: + """Tests for strict pending request response coercion.""" + + def test_event_request_id_alias_is_validated(self) -> None: + """Responses addressed to event.request_id are type-checked even when dict keys differ.""" + event = SimpleNamespace(request_id="canonical-request", response_type=bool) + + responses, error = _coerce_responses_for_pending_requests_strict( + {"canonical-request": "not-a-bool"}, + {"runner-context-key": event}, + ) + + assert responses == {} + assert error is not None + assert error.code == "WORKFLOW_RESUME_INVALID_RESPONSE" + + class TestMessageRoleValue: """Tests for _message_role_value helper.""" @@ -1283,8 +1337,8 @@ def test_empty_messages(self): # ── Stream integration tests ── -async def test_workflow_run_approval_via_messages_approved() -> None: - """Approval response sent via messages (function_approvals) should satisfy the pending request.""" +async def test_workflow_run_approval_resume_entry_approved() -> None: + """Approval response sent via canonical resume entry should satisfy the pending request.""" class ApprovalExecutor(Executor): def __init__(self) -> None: @@ -1311,28 +1365,26 @@ async def handle_approval(self, original_request: Content, response: Content, ct first_events = [ event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow) ] - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt")) + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(first_finished) assert isinstance(interrupt_payload, list) and len(interrupt_payload) == 1 - # Second turn: send approval via function_approvals on a message (not resume.interrupts) + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) resumed_events: list[Any] = [ event async for event in run_workflow_stream( { - "messages": [ + "messages": [], + "resume": [ { - "role": "user", - "content": "", - "function_approvals": [ - { - "approved": True, - "id": "approval-1", - "call_id": "refund-call", - "name": "submit_refund", - "arguments": {"order_id": "12345", "amount": "$89.99"}, - } - ], + "interruptId": "approval-1", + "status": "resolved", + "payload": { + "type": "function_approval_response", + "approved": True, + "id": "approval-1", + "function_call": interrupt_value.get("function_call"), + }, } ], }, @@ -1347,12 +1399,12 @@ async def handle_approval(self, original_request: Content, response: Content, ct assert "TEXT_MESSAGE_CONTENT" in resumed_types text_deltas = [event.delta for event in resumed_events if event.type == "TEXT_MESSAGE_CONTENT"] assert any("approved" in delta for delta in text_deltas) - resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump() - assert not resumed_finished.get("interrupt") + resumed_finished = _run_finished_dump([event for event in resumed_events if event.type == "RUN_FINISHED"][0]) + assert "outcome" not in resumed_finished -async def test_workflow_run_approval_argument_mismatch_keeps_interrupt_pending() -> None: - """Workflow approval responses must not resume with changed function arguments.""" +async def test_workflow_run_approval_argument_mismatch_emits_run_error() -> None: + """Workflow approval responses must fail when function arguments change.""" handled_responses: list[dict[str, Any]] = [] @@ -1382,27 +1434,28 @@ async def handle_approval(self, original_request: Content, response: Content, ct first_events = [ event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow) ] - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt")) + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(first_finished) assert isinstance(interrupt_payload, list) and len(interrupt_payload) == 1 + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) + mismatched_function_call = dict(cast(dict[str, Any], interrupt_value["function_call"])) + mismatched_function_call["arguments"] = {"order_id": "99999", "amount": "$1000.00"} resumed_events: list[Any] = [ event async for event in run_workflow_stream( { - "messages": [ + "messages": [], + "resume": [ { - "role": "user", - "content": "", - "function_approvals": [ - { - "approved": True, - "id": "approval-1", - "call_id": "refund-call", - "name": "submit_refund", - "arguments": {"order_id": "99999", "amount": "$1000.00"}, - } - ], + "interruptId": "approval-1", + "status": "resolved", + "payload": { + "type": "function_approval_response", + "approved": True, + "id": "approval-1", + "function_call": mismatched_function_call, + }, } ], }, @@ -1411,12 +1464,14 @@ async def handle_approval(self, original_request: Content, response: Content, ct ] assert handled_responses == [] - resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump() - assert resumed_finished.get("interrupt") + resumed_types = [event.type for event in resumed_events] + assert resumed_types == ["RUN_STARTED", "RUN_ERROR"] + run_error = [event for event in resumed_events if event.type == "RUN_ERROR"][0] + assert run_error.code == "WORKFLOW_RESUME_INVALID_RESPONSE" -async def test_workflow_run_approval_via_messages_denied() -> None: - """Denied approval response sent via messages (function_approvals) should satisfy the pending request.""" +async def test_workflow_run_approval_resume_entry_denied() -> None: + """Denied approval response sent via canonical resume entry should satisfy the pending request.""" class ApprovalExecutor(Executor): def __init__(self) -> None: @@ -1443,28 +1498,26 @@ async def handle_approval(self, original_request: Content, response: Content, ct first_events = [ event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow) ] - first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump() - interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt")) + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(first_finished) assert isinstance(interrupt_payload, list) and len(interrupt_payload) == 1 + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) - # Second turn: send denial via function_approvals on a message (not resume.interrupts) resumed_events: list[Any] = [ event async for event in run_workflow_stream( { - "messages": [ + "messages": [], + "resume": [ { - "role": "user", - "content": "", - "function_approvals": [ - { - "approved": False, - "id": "deny-1", - "call_id": "delete-call", - "name": "delete_record", - "arguments": {"record_id": "abc"}, - } - ], + "interruptId": "deny-1", + "status": "resolved", + "payload": { + "type": "function_approval_response", + "approved": False, + "id": "deny-1", + "function_call": interrupt_value.get("function_call"), + }, } ], }, @@ -1479,8 +1532,8 @@ async def handle_approval(self, original_request: Content, response: Content, ct assert "TEXT_MESSAGE_CONTENT" in resumed_types text_deltas = [event.delta for event in resumed_events if event.type == "TEXT_MESSAGE_CONTENT"] assert any("rejected" in delta for delta in text_deltas) - resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump() - assert not resumed_finished.get("interrupt") + resumed_finished = _run_finished_dump([event for event in resumed_events if event.type == "RUN_FINISHED"][0]) + assert "outcome" not in resumed_finished async def test_workflow_run_available_interrupts_logged(): diff --git a/python/uv.lock b/python/uv.lock index 0fe3298f2a3..1900787c191 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -209,7 +209,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", specifier = ">=0.1.16,<0.2" }, + { name = "ag-ui-protocol", specifier = ">=0.1.19,<0.2" }, { name = "agent-framework-core", editable = "packages/core" }, { name = "fastapi", specifier = ">=0.115.0,<0.138.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },