mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: (ag-ui): Add Workflow Support, Harden Streaming Semantics, and add Dynamic Handoff Demo (#3911)
* fix Workflow.as_agent() streaming regression in ag-ui * Address PR feedback * workflows wip * wip * wip * Workflow AG-UI demo * Fixes for handoff workflow demo * Fixes to workflows support in AG-UI * Fixes * Add headers to some demo files * Fix comment * Fixes for store * Make _input_schema lazy-loaded * fix mypy * revert session change to handoff only for now --------- Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b1c7c7c844
commit
d8b9409e96
@@ -5,17 +5,27 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard.
|
||||
## Main Classes
|
||||
|
||||
- **`AgentFrameworkAgent`** - Wraps agents for AG-UI compatibility
|
||||
- **`AgentFrameworkWorkflow`** - Wraps native `Workflow` objects, or accepts `workflow_factory(thread_id)` for thread-scoped workflow instances without subclassing
|
||||
- **`AGUIChatClient`** - Chat client that speaks AG-UI protocol
|
||||
- **`AGUIHttpService`** - HTTP service for AG-UI endpoints
|
||||
- **`AGUIEventConverter`** - Converts between Agent Framework and AG-UI events
|
||||
- **`add_agent_framework_fastapi_endpoint()`** - Add AG-UI endpoint to FastAPI app
|
||||
- **`add_agent_framework_fastapi_endpoint()`** - Add AG-UI endpoint to FastAPI app (`SupportsAgentRun` or `Workflow`)
|
||||
|
||||
## Types
|
||||
|
||||
- **`AGUIRequest`** / **`AGUIChatOptions`** - Request types
|
||||
- **`availableInterrupts` / `resume`** - Optional interrupt configuration and continuation payloads
|
||||
- **`AgentState`** / **`RunMetadata`** - State management types
|
||||
- **`PredictStateConfig`** - Configuration for state prediction
|
||||
|
||||
## Protocol Notes
|
||||
|
||||
- Outbound custom events are emitted as AG-UI `CUSTOM`.
|
||||
- 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.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
|
||||
@@ -36,6 +36,44 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
|
||||
# Run with: uvicorn main:app --reload
|
||||
```
|
||||
|
||||
### Server (Host a Workflow)
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, executor
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
@executor(id="start")
|
||||
async def start(message: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output(f"Workflow received: {message}")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).build()
|
||||
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, workflow, "/")
|
||||
```
|
||||
|
||||
### Server (Thread-Scoped WorkflowBuilder)
|
||||
|
||||
Use `workflow_factory` when your workflow keeps runtime state (for example pending `request_info` interrupts) and must be isolated per AG-UI thread:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import Workflow, WorkflowBuilder
|
||||
from agent_framework.ag_ui import AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
|
||||
|
||||
def build_workflow_for_thread(thread_id: str) -> Workflow:
|
||||
# Build a fresh workflow instance for each thread id.
|
||||
return WorkflowBuilder(start_executor=...).build()
|
||||
|
||||
app = FastAPI()
|
||||
thread_scoped_workflow = AgentFrameworkWorkflow(
|
||||
workflow_factory=build_workflow_for_thread,
|
||||
name="my_workflow",
|
||||
)
|
||||
add_agent_framework_fastapi_endpoint(app, thread_scoped_workflow, "/")
|
||||
```
|
||||
|
||||
### Client (Connect to an AG-UI Server)
|
||||
|
||||
```python
|
||||
@@ -59,6 +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`)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -81,6 +120,13 @@ This integration supports all 7 AG-UI features:
|
||||
6. **Shared State**: Bidirectional state sync between client and server
|
||||
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
|
||||
|
||||
Additional compatibility and draft support:
|
||||
- Native `Workflow` endpoint registration via `add_agent_framework_fastapi_endpoint(...)`
|
||||
- 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`)
|
||||
|
||||
## Security: Authentication & Authorization
|
||||
|
||||
The AG-UI endpoint does not enforce authentication by default. **For production deployments, you should add authentication** using FastAPI's dependency injection system via the `dependencies` parameter.
|
||||
|
||||
@@ -10,6 +10,7 @@ from ._endpoint import add_agent_framework_fastapi_endpoint
|
||||
from ._event_converters import AGUIEventConverter
|
||||
from ._http_service import AGUIHttpService
|
||||
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
|
||||
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -21,6 +22,8 @@ DEFAULT_TAGS = ["AG-UI"]
|
||||
|
||||
__all__ = [
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"WorkflowFactory",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"AGUIChatClient",
|
||||
"AGUIChatOptions",
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
from ._run import run_agent_stream
|
||||
from ._agent_run import run_agent_stream
|
||||
|
||||
|
||||
class AgentConfig:
|
||||
@@ -101,11 +101,11 @@ class AgentFrameworkAgent:
|
||||
require_confirmation=require_confirmation,
|
||||
)
|
||||
|
||||
async def run_agent(
|
||||
async def run(
|
||||
self,
|
||||
input_data: dict[str, Any],
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Run the agent and yield AG-UI events.
|
||||
"""Run the wrapped agent and yield AG-UI events.
|
||||
|
||||
Args:
|
||||
input_data: The AG-UI run input containing messages, state, etc.
|
||||
|
||||
+51
-283
@@ -2,20 +2,18 @@
|
||||
|
||||
"""Simplified AG-UI orchestration - single linear flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations # noqa: I001
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
MessagesSnapshotEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
@@ -23,7 +21,6 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
@@ -45,6 +42,14 @@ from agent_framework.exceptions import AgentInvalidResponseException
|
||||
from ._message_adapters import normalize_agui_input_messages
|
||||
from ._orchestration._predictive_state import PredictiveStateHandler
|
||||
from ._orchestration._tooling import collect_server_tools, merge_tools, register_additional_client_tools
|
||||
from ._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event, # type: ignore
|
||||
_emit_content, # type: ignore
|
||||
_extract_resume_payload, # type: ignore
|
||||
_has_only_tool_calls, # type: ignore
|
||||
_normalize_resume_interrupts, # type: ignore
|
||||
)
|
||||
from ._utils import (
|
||||
convert_agui_tools_to_agent_framework,
|
||||
generate_event_id,
|
||||
@@ -86,20 +91,6 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An
|
||||
return safe_metadata
|
||||
|
||||
|
||||
def _has_only_tool_calls(contents: list[Any]) -> bool:
|
||||
"""Check if contents have only tool calls (no text).
|
||||
|
||||
Args:
|
||||
contents: List of content items
|
||||
|
||||
Returns:
|
||||
True if there are tool calls but no text content
|
||||
"""
|
||||
has_tool_call = any(getattr(c, "type", None) == "function_call" for c in contents)
|
||||
has_text = any(getattr(c, "type", None) == "text" and getattr(c, "text", None) for c in contents)
|
||||
return has_tool_call and not has_text
|
||||
|
||||
|
||||
def _should_suppress_intermediate_snapshot(
|
||||
tool_name: str | None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
@@ -164,31 +155,24 @@ def _extract_approved_state_updates(
|
||||
return updates
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowState:
|
||||
"""Minimal explicit state for a single AG-UI run."""
|
||||
|
||||
message_id: str | None = None # Current text message being streamed
|
||||
tool_call_id: str | None = None # Current tool call being streamed
|
||||
tool_call_name: str | None = None # Name of current tool call
|
||||
waiting_for_approval: bool = False # Stop after approval request
|
||||
current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
accumulated_text: str = "" # For MessagesSnapshotEvent
|
||||
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
def get_tool_name(self, call_id: str | None) -> str | None:
|
||||
"""Get tool name by call ID."""
|
||||
if not call_id or call_id not in self.tool_calls_by_id:
|
||||
return None
|
||||
name = self.tool_calls_by_id[call_id]["function"].get("name")
|
||||
return str(name) if name else None
|
||||
|
||||
def get_pending_without_end(self) -> list[dict[str, Any]]:
|
||||
"""Get tool calls that started but never received an end event (declaration-only)."""
|
||||
return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended]
|
||||
def _resume_to_tool_messages(resume_payload: Any) -> 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):
|
||||
value = interrupt.get("value")
|
||||
content: str
|
||||
if isinstance(value, str):
|
||||
content = value
|
||||
else:
|
||||
content = json.dumps(make_json_safe(value))
|
||||
result.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": interrupt["id"],
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _normalize_response_stream(response_stream: Any) -> AsyncIterable[Any]:
|
||||
@@ -303,242 +287,6 @@ def _inject_state_context(
|
||||
return result
|
||||
|
||||
|
||||
def _emit_text(content: Content, flow: FlowState, skip_text: bool = False) -> list[BaseEvent]:
|
||||
"""Emit TextMessage events for TextContent."""
|
||||
if not content.text:
|
||||
return []
|
||||
|
||||
# Skip if we're in structured output mode or waiting for approval
|
||||
if skip_text or flow.waiting_for_approval:
|
||||
return []
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
if not flow.message_id:
|
||||
flow.message_id = generate_event_id()
|
||||
events.append(TextMessageStartEvent(message_id=flow.message_id, role="assistant"))
|
||||
|
||||
events.append(TextMessageContentEvent(message_id=flow.message_id, delta=content.text))
|
||||
flow.accumulated_text += content.text
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_call(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCall events for FunctionCallContent."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
tool_call_id = content.call_id or flow.tool_call_id or generate_event_id()
|
||||
|
||||
# Emit start event when we have a new tool call
|
||||
if content.name and tool_call_id != flow.tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
flow.tool_call_name = content.name
|
||||
if predictive_handler:
|
||||
predictive_handler.reset_streaming()
|
||||
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=content.name,
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Track for MessagesSnapshotEvent
|
||||
tool_entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": content.name, "arguments": ""},
|
||||
}
|
||||
flow.pending_tool_calls.append(tool_entry)
|
||||
flow.tool_calls_by_id[tool_call_id] = tool_entry
|
||||
|
||||
elif tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
|
||||
# Emit args if present
|
||||
if content.arguments:
|
||||
delta = (
|
||||
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
|
||||
)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=delta))
|
||||
|
||||
# Track args for MessagesSnapshotEvent
|
||||
if tool_call_id in flow.tool_calls_by_id:
|
||||
flow.tool_calls_by_id[tool_call_id]["function"]["arguments"] += delta
|
||||
|
||||
# Emit predictive state deltas
|
||||
if predictive_handler and flow.tool_call_name:
|
||||
delta_events = predictive_handler.emit_streaming_deltas(flow.tool_call_name, delta)
|
||||
events.extend(delta_events)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
# Cannot emit tool result without a call_id to associate it with
|
||||
if not content.call_id:
|
||||
return events
|
||||
|
||||
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
|
||||
flow.tool_calls_ended.add(content.call_id) # Track ended tool calls
|
||||
|
||||
result_content = content.result if content.result is not None else ""
|
||||
message_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=message_id,
|
||||
tool_call_id=content.call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
|
||||
# Track for MessagesSnapshotEvent
|
||||
flow.tool_results.append(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
# Apply predictive state updates and emit snapshot
|
||||
if predictive_handler:
|
||||
predictive_handler.apply_pending_updates()
|
||||
if flow.current_state:
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
# Reset tool tracking and message context
|
||||
# After tool result, any subsequent text should start a new message
|
||||
flow.tool_call_id = None
|
||||
flow.tool_call_name = None
|
||||
|
||||
# Close any open text message before resetting message_id (issue #3568)
|
||||
# This handles the case where a TextMessageStartEvent was emitted for tool-only
|
||||
# messages (Feature #4) but needs to be closed before starting a new message
|
||||
if flow.message_id:
|
||||
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
|
||||
events.append(TextMessageEndEvent(message_id=flow.message_id))
|
||||
flow.message_id = None # Reset so next text content starts a new message
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_approval_request(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit events for function approval request."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
# function_call is required for approval requests - skip if missing
|
||||
func_call = content.function_call
|
||||
if not func_call:
|
||||
logger.warning("Approval request content missing function_call, skipping")
|
||||
return events
|
||||
|
||||
func_name = func_call.name or ""
|
||||
func_call_id = func_call.call_id
|
||||
|
||||
# Extract state from function arguments if predictive
|
||||
if predictive_handler and func_name:
|
||||
parsed_args = func_call.parse_arguments()
|
||||
result = predictive_handler.extract_state_value(func_name, parsed_args)
|
||||
if result:
|
||||
state_key, state_value = result
|
||||
flow.current_state[state_key] = state_value
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
# End the original tool call
|
||||
if func_call_id:
|
||||
events.append(ToolCallEndEvent(tool_call_id=func_call_id))
|
||||
flow.tool_calls_ended.add(func_call_id) # Track ended tool calls
|
||||
|
||||
# Emit custom event for UI
|
||||
events.append(
|
||||
CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
"id": content.id,
|
||||
"function_call": {
|
||||
"call_id": func_call_id,
|
||||
"name": func_name,
|
||||
"arguments": make_json_safe(func_call.parse_arguments()),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Emit confirm_changes tool call for UI compatibility
|
||||
# The complete sequence (Start -> Args -> End) signals the UI to show the confirmation dialog
|
||||
if require_confirmation:
|
||||
confirm_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=confirm_id,
|
||||
tool_call_name="confirm_changes",
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
args: dict[str, Any] = {
|
||||
"function_name": func_name,
|
||||
"function_call_id": func_call_id,
|
||||
"function_arguments": make_json_safe(func_call.parse_arguments()) or {},
|
||||
"steps": [{"description": f"Execute {func_name}", "status": "enabled"}],
|
||||
}
|
||||
args_json = json.dumps(args)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=confirm_id, delta=args_json))
|
||||
events.append(ToolCallEndEvent(tool_call_id=confirm_id))
|
||||
|
||||
# Track confirm_changes in pending_tool_calls for MessagesSnapshotEvent
|
||||
# The frontend needs to see this in the snapshot to render the confirmation dialog
|
||||
confirm_entry = {
|
||||
"id": confirm_id,
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": args_json},
|
||||
}
|
||||
flow.pending_tool_calls.append(confirm_entry)
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
|
||||
|
||||
flow.waiting_for_approval = True
|
||||
return events
|
||||
|
||||
|
||||
def _emit_content(
|
||||
content: Any,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
skip_text: bool = False,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit appropriate events for any content type."""
|
||||
content_type = getattr(content, "type", None)
|
||||
if content_type == "text":
|
||||
return _emit_text(content, flow, skip_text)
|
||||
elif content_type == "function_call":
|
||||
return _emit_tool_call(content, flow, predictive_handler)
|
||||
elif content_type == "function_result":
|
||||
return _emit_tool_result(content, flow, predictive_handler)
|
||||
elif content_type == "function_approval_request":
|
||||
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
|
||||
return []
|
||||
|
||||
|
||||
def _is_confirm_changes_response(messages: list[Any]) -> bool:
|
||||
"""Check if the last message is a confirm_changes tool result (state confirmation flow).
|
||||
|
||||
@@ -831,7 +579,14 @@ async def run_agent_stream(
|
||||
)
|
||||
|
||||
# Normalize messages
|
||||
raw_messages = input_data.get("messages", [])
|
||||
available_interrupts = input_data.get("available_interrupts") or input_data.get("availableInterrupts")
|
||||
raw_messages = list(cast(list[dict[str, Any]], input_data.get("messages", []) or []))
|
||||
resume_messages = _resume_to_tool_messages(_extract_resume_payload(input_data))
|
||||
if available_interrupts:
|
||||
logger.debug("Received available interrupts metadata: %s", available_interrupts)
|
||||
if resume_messages:
|
||||
logger.info(f"Appending {len(resume_messages)} synthesized resume message(s) to AG-UI input.")
|
||||
raw_messages.extend(resume_messages)
|
||||
messages, snapshot_messages = normalize_agui_input_messages(raw_messages)
|
||||
|
||||
# Check for structured output mode (skip text content)
|
||||
@@ -847,7 +602,7 @@ async def run_agent_stream(
|
||||
if not messages:
|
||||
logger.warning("No messages provided in AG-UI input")
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
|
||||
return
|
||||
|
||||
# Prepare tools
|
||||
@@ -906,7 +661,7 @@ async def run_agent_stream(
|
||||
yield StateSnapshotEvent(snapshot=flow.current_state)
|
||||
for event in _handle_step_based_approval(messages):
|
||||
yield event
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
|
||||
return
|
||||
|
||||
# Inject state context message so the model knows current application state
|
||||
@@ -1099,6 +854,19 @@ async def run_agent_stream(
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
|
||||
flow.waiting_for_approval = True
|
||||
flow.interrupts = [
|
||||
{
|
||||
"id": str(confirm_id),
|
||||
"value": {
|
||||
"type": "function_approval_request",
|
||||
"function_call": {
|
||||
"call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"arguments": function_arguments,
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Close any open message
|
||||
if flow.message_id:
|
||||
@@ -1122,4 +890,4 @@ 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
|
||||
yield RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=flow.interrupts)
|
||||
@@ -439,6 +439,11 @@ class AGUIChatClient(
|
||||
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")),
|
||||
):
|
||||
logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}")
|
||||
update = converter.convert_event(event)
|
||||
|
||||
@@ -9,21 +9,23 @@ import logging
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import RunErrorEvent
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agent_framework import SupportsAgentRun
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import SupportsAgentRun, Workflow
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ._agent import AgentFrameworkAgent
|
||||
from ._types import AGUIRequest
|
||||
from ._workflow import AgentFrameworkWorkflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def add_agent_framework_fastapi_endpoint(
|
||||
app: FastAPI,
|
||||
agent: SupportsAgentRun | AgentFrameworkAgent,
|
||||
agent: SupportsAgentRun | AgentFrameworkAgent | Workflow | AgentFrameworkWorkflow,
|
||||
path: str = "/",
|
||||
state_schema: Any | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
@@ -49,17 +51,24 @@ def add_agent_framework_fastapi_endpoint(
|
||||
authentication checks, rate limiting, or other middleware-like behavior.
|
||||
Example: `dependencies=[Depends(verify_api_key)]`
|
||||
"""
|
||||
if isinstance(agent, SupportsAgentRun):
|
||||
wrapped_agent = AgentFrameworkAgent(
|
||||
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow
|
||||
if isinstance(agent, AgentFrameworkWorkflow):
|
||||
protocol_runner = agent
|
||||
elif isinstance(agent, AgentFrameworkAgent):
|
||||
protocol_runner = agent
|
||||
elif isinstance(agent, Workflow):
|
||||
protocol_runner = AgentFrameworkWorkflow(workflow=agent)
|
||||
elif isinstance(agent, SupportsAgentRun):
|
||||
protocol_runner = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
state_schema=state_schema,
|
||||
predict_state_config=predict_state_config,
|
||||
)
|
||||
else:
|
||||
wrapped_agent = agent
|
||||
raise TypeError("agent must be SupportsAgentRun, Workflow, AgentFrameworkAgent, or AgentFrameworkWorkflow.")
|
||||
|
||||
@app.post(path, tags=tags or ["AG-UI"], dependencies=dependencies, response_model=None) # type: ignore[arg-type]
|
||||
async def agent_endpoint(request_body: AGUIRequest) -> StreamingResponse | dict[str, str]:
|
||||
async def agent_endpoint(request_body: AGUIRequest) -> StreamingResponse:
|
||||
"""Handle AG-UI agent requests.
|
||||
|
||||
Note: Function is accessed via FastAPI's decorator registration,
|
||||
@@ -82,25 +91,50 @@ def add_agent_framework_fastapi_endpoint(
|
||||
async def event_generator() -> AsyncGenerator[str]:
|
||||
encoder = EventEncoder()
|
||||
event_count = 0
|
||||
async for event in wrapped_agent.run_agent(input_data):
|
||||
event_count += 1
|
||||
event_type_name = getattr(event, "type", type(event).__name__)
|
||||
# Log important events at INFO level
|
||||
if "TOOL_CALL" in str(event_type_name) or "RUN" in str(event_type_name):
|
||||
if hasattr(event, "model_dump"):
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
logger.info(f"[{path}] Event {event_count}: {event_type_name} - {event_data}")
|
||||
else:
|
||||
logger.info(f"[{path}] Event {event_count}: {event_type_name}")
|
||||
try:
|
||||
async for event in protocol_runner.run(input_data):
|
||||
event_count += 1
|
||||
event_type_name = getattr(event, "type", type(event).__name__)
|
||||
# Log important events at INFO level
|
||||
if "TOOL_CALL" in str(event_type_name) or "RUN" in str(event_type_name):
|
||||
if hasattr(event, "model_dump"):
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
logger.info(f"[{path}] Event {event_count}: {event_type_name} - {event_data}")
|
||||
else:
|
||||
logger.info(f"[{path}] Event {event_count}: {event_type_name}")
|
||||
|
||||
encoded = encoder.encode(event)
|
||||
logger.debug(
|
||||
f"[{path}] Encoded as: {encoded[:200]}..."
|
||||
if len(encoded) > 200
|
||||
else f"[{path}] Encoded as: {encoded}"
|
||||
try:
|
||||
encoded = encoder.encode(event)
|
||||
except Exception as encode_error:
|
||||
logger.exception("[%s] Failed to encode event %s", path, event_type_name)
|
||||
run_error = RunErrorEvent(
|
||||
message="An internal error has occurred while streaming events.",
|
||||
code=type(encode_error).__name__,
|
||||
)
|
||||
try:
|
||||
yield encoder.encode(run_error)
|
||||
except Exception:
|
||||
logger.exception("[%s] Failed to encode RUN_ERROR event", path)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"[{path}] Encoded as: {encoded[:200]}..."
|
||||
if len(encoded) > 200
|
||||
else f"[{path}] Encoded as: {encoded}"
|
||||
)
|
||||
yield encoded
|
||||
|
||||
logger.info(f"[{path}] Completed streaming {event_count} events")
|
||||
except Exception as stream_error:
|
||||
logger.exception("[%s] Streaming failed", path)
|
||||
run_error = RunErrorEvent(
|
||||
message="An internal error has occurred while streaming events.",
|
||||
code=type(stream_error).__name__,
|
||||
)
|
||||
yield encoded
|
||||
logger.info(f"[{path}] Completed streaming {event_count} events")
|
||||
try:
|
||||
yield encoder.encode(run_error)
|
||||
except Exception:
|
||||
logger.exception("[%s] Failed to encode RUN_ERROR event", path)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
@@ -113,4 +147,4 @@ def add_agent_framework_fastapi_endpoint(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
|
||||
return {"error": "An internal error has occurred."}
|
||||
raise HTTPException(status_code=500, detail="An internal error has occurred.") from e
|
||||
|
||||
@@ -55,7 +55,8 @@ class AGUIEventConverter:
|
||||
update = converter.convert_event(event)
|
||||
assert update.contents[0].text == "Hello"
|
||||
"""
|
||||
event_type = event.get("type", "")
|
||||
raw_event_type = str(event.get("type", ""))
|
||||
event_type = raw_event_type.upper()
|
||||
|
||||
if event_type == "RUN_STARTED":
|
||||
return self._handle_run_started(event)
|
||||
@@ -77,6 +78,8 @@ class AGUIEventConverter:
|
||||
return self._handle_run_finished(event)
|
||||
elif event_type == "RUN_ERROR":
|
||||
return self._handle_run_error(event)
|
||||
elif event_type in {"CUSTOM", "CUSTOM_EVENT"}:
|
||||
return self._handle_custom_event(event, raw_event_type)
|
||||
|
||||
return None
|
||||
|
||||
@@ -176,14 +179,20 @@ class AGUIEventConverter:
|
||||
|
||||
def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate:
|
||||
"""Handle RUN_FINISHED event."""
|
||||
additional_properties: dict[str, Any] = {
|
||||
"thread_id": self.thread_id,
|
||||
"run_id": self.run_id,
|
||||
}
|
||||
if "interrupt" in event:
|
||||
additional_properties["interrupt"] = event.get("interrupt")
|
||||
if "result" in event:
|
||||
additional_properties["result"] = event.get("result")
|
||||
|
||||
return ChatResponseUpdate(
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
contents=[],
|
||||
additional_properties={
|
||||
"thread_id": self.thread_id,
|
||||
"run_id": self.run_id,
|
||||
},
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:
|
||||
@@ -204,3 +213,22 @@ class AGUIEventConverter:
|
||||
"run_id": self.run_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_custom_event(self, event: dict[str, Any], raw_event_type: str) -> ChatResponseUpdate:
|
||||
"""Handle CUSTOM/CUSTOM_EVENT events.
|
||||
|
||||
Custom events are surfaced as metadata so callers can inspect protocol-specific payloads.
|
||||
"""
|
||||
return ChatResponseUpdate(
|
||||
role="assistant",
|
||||
contents=[],
|
||||
additional_properties={
|
||||
"thread_id": self.thread_id,
|
||||
"run_id": self.run_id,
|
||||
"ag_ui_custom_event": {
|
||||
"name": event.get("name"),
|
||||
"value": event.get("value"),
|
||||
"raw_type": raw_event_type,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -66,6 +66,8 @@ class AGUIHttpService:
|
||||
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,
|
||||
) -> AsyncIterable[dict[str, Any]]:
|
||||
"""Post a run request and stream AG-UI events.
|
||||
|
||||
@@ -75,6 +77,8 @@ class AGUIHttpService:
|
||||
messages: List of messages in AG-UI format
|
||||
state: Optional state object to send to server
|
||||
tools: Optional list of tools available to the agent
|
||||
available_interrupts: Optional list of interrupt descriptors available for resumption
|
||||
resume: Optional resume payload to continue a paused run
|
||||
|
||||
Yields:
|
||||
AG-UI event dictionaries parsed from SSE stream
|
||||
@@ -109,9 +113,16 @@ class AGUIHttpService:
|
||||
if tools is not None:
|
||||
request_data["tools"] = tools
|
||||
|
||||
if available_interrupts is not None:
|
||||
request_data["availableInterrupts"] = available_interrupts
|
||||
|
||||
if resume is not None:
|
||||
request_data["resume"] = resume
|
||||
|
||||
logger.debug(
|
||||
f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, "
|
||||
f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}"
|
||||
f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}, "
|
||||
f"has_available_interrupts={available_interrupts is not None}, has_resume={resume is not None}"
|
||||
)
|
||||
|
||||
# Stream the response using SSE
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
@@ -253,12 +255,235 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
|
||||
return unique_messages
|
||||
|
||||
|
||||
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
|
||||
"""Convert a multimodal media part into Agent Framework content."""
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
source = part.get("source")
|
||||
|
||||
mime_type = cast(
|
||||
str | None,
|
||||
part.get("mimeType")
|
||||
or part.get("mime_type")
|
||||
or {
|
||||
"image": "image/*",
|
||||
"audio": "audio/*",
|
||||
"video": "video/*",
|
||||
"document": "application/octet-stream",
|
||||
"binary": "application/octet-stream",
|
||||
}.get(part_type, "application/octet-stream"),
|
||||
)
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
if isinstance(source, dict):
|
||||
source_dict = cast(dict[str, Any], source)
|
||||
source_type = str(source_dict.get("type", "")).lower()
|
||||
source_mime = source_dict.get("mimeType") or source_dict.get("mime_type")
|
||||
if isinstance(source_mime, str) and source_mime:
|
||||
mime_type = source_mime
|
||||
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_dict.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_dict.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url)
|
||||
data = cast(str | None, source_dict.get("data") or data)
|
||||
binary_id = cast(str | None, source_dict.get("id") or binary_id)
|
||||
|
||||
if isinstance(url, str) and url:
|
||||
return Content.from_uri(uri=url, media_type=mime_type)
|
||||
|
||||
if isinstance(data, str) and data:
|
||||
if data.startswith("data:"):
|
||||
return Content.from_uri(uri=data, media_type=mime_type)
|
||||
try:
|
||||
decoded = base64.b64decode(data, validate=True)
|
||||
return Content.from_data(data=decoded, media_type=mime_type or "application/octet-stream")
|
||||
except (binascii.Error, ValueError):
|
||||
logger.debug("Strict base64 decode failed for AG-UI media payload (mime_type=%s).", mime_type)
|
||||
try:
|
||||
decoded = base64.b64decode(data)
|
||||
return Content.from_data(data=decoded, media_type=mime_type or "application/octet-stream")
|
||||
except (binascii.Error, ValueError):
|
||||
logger.warning(
|
||||
"Failed to decode AG-UI media payload as base64; falling back to data URI (mime_type=%s).",
|
||||
mime_type,
|
||||
exc_info=True,
|
||||
)
|
||||
# Best effort fallback for malformed payloads.
|
||||
return Content.from_uri(
|
||||
uri=f"data:{mime_type or 'application/octet-stream'};base64,{data}",
|
||||
media_type=mime_type,
|
||||
)
|
||||
|
||||
if isinstance(binary_id, str) and binary_id:
|
||||
return Content.from_uri(uri=f"ag-ui://binary/{binary_id}", media_type=mime_type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _convert_agui_content_to_framework(content: Any) -> list[Content]:
|
||||
"""Convert AG-UI content payloads to Agent Framework Content entries."""
|
||||
if isinstance(content, str):
|
||||
return [Content.from_text(text=content)]
|
||||
|
||||
if isinstance(content, list):
|
||||
converted: list[Content] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
converted.append(Content.from_text(text=item))
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
converted.append(Content.from_text(text=str(item)))
|
||||
continue
|
||||
|
||||
part = cast(dict[str, Any], item)
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
|
||||
if part_type in {"text", "input_text"}:
|
||||
converted.append(Content.from_text(text=str(part.get("text", ""))))
|
||||
continue
|
||||
|
||||
if part_type in {"binary", "image", "audio", "video", "document"}:
|
||||
media_content = _parse_multimodal_media_part(part)
|
||||
if media_content is not None:
|
||||
converted.append(media_content)
|
||||
continue
|
||||
|
||||
text_value = part.get("text")
|
||||
if isinstance(text_value, str):
|
||||
converted.append(Content.from_text(text=text_value))
|
||||
else:
|
||||
converted.append(Content.from_text(text=str(part)))
|
||||
|
||||
return converted
|
||||
|
||||
if content is None:
|
||||
return []
|
||||
|
||||
return [Content.from_text(text=str(content))]
|
||||
|
||||
|
||||
def _normalize_snapshot_content(content: Any) -> Any:
|
||||
"""Normalize AG-UI message content for snapshot payloads.
|
||||
|
||||
Preserve multimodal fidelity whenever non-text parts are present.
|
||||
"""
|
||||
if isinstance(content, list):
|
||||
has_non_text_parts = False
|
||||
normalized_parts: list[dict[str, Any]] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert draft/legacy multimodal parts to AG-UI snapshot binary shape."""
|
||||
normalized: dict[str, Any] = {"type": "binary"}
|
||||
|
||||
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
|
||||
url = cast(str | None, part.get("url") or part.get("uri"))
|
||||
data = cast(str | None, part.get("data"))
|
||||
binary_id = cast(str | None, part.get("id"))
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_part = cast(dict[str, Any], source)
|
||||
source_mime = source_part.get("mimeType") or source_part.get("mime_type")
|
||||
if isinstance(source_mime, str) and source_mime:
|
||||
mime_type = source_mime
|
||||
|
||||
source_type = str(source_part.get("type", "")).lower()
|
||||
if source_type in {"url", "uri"}:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri"))
|
||||
elif source_type in {"base64", "data", "binary"}:
|
||||
data = cast(str | None, source_part.get("data"))
|
||||
elif source_type in {"id", "file"}:
|
||||
binary_id = cast(str | None, source_part.get("id"))
|
||||
else:
|
||||
url = cast(str | None, source_part.get("url") or source_part.get("uri") or url)
|
||||
data = cast(str | None, source_part.get("data") or data)
|
||||
binary_id = cast(str | None, source_part.get("id") or binary_id)
|
||||
|
||||
if isinstance(mime_type, str) and mime_type:
|
||||
normalized["mimeType"] = mime_type
|
||||
if isinstance(url, str) and url:
|
||||
normalized["url"] = url
|
||||
if isinstance(data, str) and data:
|
||||
normalized["data"] = data
|
||||
if isinstance(binary_id, str) and binary_id:
|
||||
normalized["id"] = binary_id
|
||||
|
||||
return normalized
|
||||
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
normalized_parts.append({"type": "text", "text": item})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
item_text = str(item)
|
||||
text_parts.append(item_text)
|
||||
normalized_parts.append({"type": "text", "text": item_text})
|
||||
continue
|
||||
|
||||
part = cast(dict[str, Any], item).copy()
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
|
||||
if part_type == "input_text":
|
||||
part["type"] = "text"
|
||||
part_type = "text"
|
||||
elif part_type == "input_image":
|
||||
part["type"] = "binary"
|
||||
part_type = "binary"
|
||||
|
||||
if part_type == "text":
|
||||
text_parts.append(str(part.get("text", "")))
|
||||
else:
|
||||
has_non_text_parts = True
|
||||
if part_type in {"binary", "image", "audio", "video", "document"}:
|
||||
normalized_parts.append(_legacy_binary_part(part))
|
||||
continue
|
||||
|
||||
if "mime_type" in part and "mimeType" not in part:
|
||||
part["mimeType"] = part.get("mime_type")
|
||||
|
||||
source = part.get("source")
|
||||
if isinstance(source, dict):
|
||||
source_part = cast(dict[str, Any], source)
|
||||
if "mime_type" in source_part and "mimeType" not in source_part:
|
||||
source_part["mimeType"] = source_part.get("mime_type")
|
||||
|
||||
normalized_parts.append(part)
|
||||
|
||||
if has_non_text_parts:
|
||||
return normalized_parts
|
||||
|
||||
return "".join(text_parts)
|
||||
|
||||
if content is None:
|
||||
return ""
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def normalize_agui_input_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
sanitize_tool_history: bool = True,
|
||||
) -> tuple[list[Message], list[dict[str, Any]]]:
|
||||
"""Normalize raw AG-UI messages into provider and snapshot formats."""
|
||||
"""Normalize raw AG-UI messages into provider and snapshot formats.
|
||||
|
||||
Args:
|
||||
messages: Raw AG-UI messages.
|
||||
sanitize_tool_history: Apply agent-run specific tool history repair logic.
|
||||
Keep enabled for standard agent runs; disable for native workflow runs
|
||||
where pending-request responses must come explicitly from interrupt resume.
|
||||
"""
|
||||
provider_messages = agui_messages_to_agent_framework(messages)
|
||||
provider_messages = _sanitize_tool_history(provider_messages)
|
||||
if sanitize_tool_history:
|
||||
provider_messages = _sanitize_tool_history(provider_messages)
|
||||
provider_messages = _deduplicate_messages(provider_messages)
|
||||
snapshot_messages = agui_messages_to_snapshot_format(messages)
|
||||
return provider_messages, snapshot_messages
|
||||
@@ -562,10 +787,10 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes
|
||||
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
|
||||
if tool_calls:
|
||||
contents: list[Any] = []
|
||||
# Include any assistant text content if present
|
||||
content_text = msg.get("content")
|
||||
if isinstance(content_text, str) and content_text:
|
||||
contents.append(Content.from_text(text=content_text))
|
||||
# Include any assistant content if present
|
||||
content_value = msg.get("content")
|
||||
if content_value not in (None, ""):
|
||||
contents.extend(_convert_agui_content_to_framework(content_value))
|
||||
# Convert each tool call entry
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
@@ -620,12 +845,12 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes
|
||||
|
||||
chat_msg = Message(role=role, contents=approval_contents) # type: ignore[call-overload]
|
||||
else:
|
||||
# Regular text message
|
||||
# Regular message content (text or multimodal)
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
chat_msg = Message(role=role, contents=[Content.from_text(text=content)]) # type: ignore[call-overload]
|
||||
else:
|
||||
chat_msg = Message(role=role, contents=[Content.from_text(text=str(content))]) # type: ignore[call-overload]
|
||||
converted_contents = _convert_agui_content_to_framework(content)
|
||||
if not converted_contents:
|
||||
converted_contents = [Content.from_text(text="")]
|
||||
chat_msg = Message(role=role, contents=converted_contents) # type: ignore[call-overload]
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
@@ -760,23 +985,7 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
|
||||
normalized_msg["id"] = generate_event_id()
|
||||
|
||||
# Normalize content field
|
||||
content = normalized_msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Convert content array format to simple string
|
||||
text_parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
# Convert 'input_text' to 'text' type
|
||||
if item.get("type") == "input_text":
|
||||
text_parts.append(str(item.get("text", "")))
|
||||
elif item.get("type") == "text":
|
||||
text_parts.append(str(item.get("text", "")))
|
||||
else:
|
||||
# Other types - just extract text field if present
|
||||
text_parts.append(str(item.get("text", "")))
|
||||
normalized_msg["content"] = "".join(text_parts)
|
||||
elif content is None:
|
||||
normalized_msg["content"] = ""
|
||||
normalized_msg["content"] = _normalize_snapshot_content(normalized_msg.get("content"))
|
||||
|
||||
tool_calls = normalized_msg.get("tool_calls") or normalized_msg.get("toolCalls")
|
||||
if isinstance(tool_calls, list):
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
"""Helper functions for orchestration logic.
|
||||
|
||||
Most orchestration helpers have been moved inline to _run.py.
|
||||
This module retains utilities that may be useful for testing or extensions.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared AG-UI run helpers used by agent and workflow runners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
RunFinishedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import Content
|
||||
|
||||
from ._orchestration._predictive_state import PredictiveStateHandler
|
||||
from ._utils import generate_event_id, make_json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _has_only_tool_calls(contents: list[Any]) -> bool:
|
||||
"""Check if contents have only tool calls (no text)."""
|
||||
has_tool_call = any(getattr(c, "type", None) == "function_call" for c in contents)
|
||||
has_text = any(getattr(c, "type", None) == "text" and getattr(c, "text", None) for c in contents)
|
||||
return has_tool_call and not has_text
|
||||
|
||||
|
||||
def _normalize_resume_interrupts(resume_payload: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize resume payload to a list of interrupt responses."""
|
||||
if resume_payload is None:
|
||||
return []
|
||||
|
||||
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 []
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in candidates:
|
||||
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")
|
||||
if not interrupt_id:
|
||||
continue
|
||||
|
||||
if "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"}}
|
||||
|
||||
normalized.append({"id": str(interrupt_id), "value": value})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_resume_payload(input_data: dict[str, Any]) -> Any:
|
||||
"""Extract resume payload from standard and forwarded-props request locations."""
|
||||
resume_payload = input_data.get("resume")
|
||||
if resume_payload is not None:
|
||||
return resume_payload
|
||||
|
||||
forwarded_props = input_data.get("forwarded_props") or input_data.get("forwardedProps")
|
||||
if not isinstance(forwarded_props, dict):
|
||||
return None
|
||||
|
||||
forwarded_props_dict = cast(dict[str, Any], forwarded_props)
|
||||
command = forwarded_props_dict.get("command")
|
||||
if isinstance(command, dict):
|
||||
command_dict = cast(dict[str, Any], command)
|
||||
if command_dict.get("resume") is not None:
|
||||
return command_dict.get("resume")
|
||||
|
||||
return forwarded_props_dict.get("resume")
|
||||
|
||||
|
||||
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]
|
||||
return RunFinishedEvent(run_id=run_id, thread_id=thread_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowState:
|
||||
"""Minimal explicit state for a single AG-UI run."""
|
||||
|
||||
message_id: str | None = None
|
||||
tool_call_id: str | None = None
|
||||
tool_call_name: str | None = None
|
||||
waiting_for_approval: bool = False
|
||||
current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
accumulated_text: str = ""
|
||||
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
|
||||
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
def get_tool_name(self, call_id: str | None) -> str | None:
|
||||
"""Get tool name by call ID."""
|
||||
if not call_id or call_id not in self.tool_calls_by_id:
|
||||
return None
|
||||
name = self.tool_calls_by_id[call_id]["function"].get("name")
|
||||
return str(name) if name else None
|
||||
|
||||
def get_pending_without_end(self) -> list[dict[str, Any]]:
|
||||
"""Get tool calls that started but never received an end event (declaration-only)."""
|
||||
return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended]
|
||||
|
||||
|
||||
def _emit_text(content: Content, flow: FlowState, skip_text: bool = False) -> list[BaseEvent]:
|
||||
"""Emit TextMessage events for TextContent."""
|
||||
if not content.text:
|
||||
return []
|
||||
|
||||
if skip_text or flow.waiting_for_approval:
|
||||
return []
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
if not flow.message_id:
|
||||
flow.message_id = generate_event_id()
|
||||
flow.accumulated_text = ""
|
||||
events.append(TextMessageStartEvent(message_id=flow.message_id, role="assistant"))
|
||||
elif flow.accumulated_text and content.text == flow.accumulated_text:
|
||||
# Guard against full-message replay chunks that can appear after streaming deltas.
|
||||
logger.debug("Skipping duplicate full-text delta for message_id=%s", flow.message_id)
|
||||
return []
|
||||
|
||||
events.append(TextMessageContentEvent(message_id=flow.message_id, delta=content.text))
|
||||
flow.accumulated_text += content.text
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_call(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCall events for FunctionCallContent."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
tool_call_id = content.call_id or flow.tool_call_id or generate_event_id()
|
||||
|
||||
if content.name and tool_call_id != flow.tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
flow.tool_call_name = content.name
|
||||
if predictive_handler:
|
||||
predictive_handler.reset_streaming()
|
||||
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=content.name,
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
|
||||
tool_entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": content.name, "arguments": ""},
|
||||
}
|
||||
flow.pending_tool_calls.append(tool_entry)
|
||||
flow.tool_calls_by_id[tool_call_id] = tool_entry
|
||||
|
||||
elif tool_call_id:
|
||||
flow.tool_call_id = tool_call_id
|
||||
|
||||
if content.arguments:
|
||||
delta = (
|
||||
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
|
||||
)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=delta))
|
||||
|
||||
if tool_call_id in flow.tool_calls_by_id:
|
||||
flow.tool_calls_by_id[tool_call_id]["function"]["arguments"] += delta
|
||||
|
||||
if predictive_handler and flow.tool_call_name:
|
||||
delta_events = predictive_handler.emit_streaming_deltas(flow.tool_call_name, delta)
|
||||
events.extend(delta_events)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
if not content.call_id:
|
||||
return events
|
||||
|
||||
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
|
||||
flow.tool_calls_ended.add(content.call_id)
|
||||
|
||||
raw_result = content.result if content.result is not None else ""
|
||||
result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result))
|
||||
message_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=message_id,
|
||||
tool_call_id=content.call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
|
||||
flow.tool_results.append(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
if predictive_handler:
|
||||
predictive_handler.apply_pending_updates()
|
||||
if flow.current_state:
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
flow.tool_call_id = None
|
||||
flow.tool_call_name = None
|
||||
|
||||
if flow.message_id:
|
||||
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
|
||||
events.append(TextMessageEndEvent(message_id=flow.message_id))
|
||||
flow.message_id = None
|
||||
flow.accumulated_text = ""
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_approval_request(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit events for function approval request."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
func_call = content.function_call
|
||||
if not func_call:
|
||||
logger.warning("Approval request content missing function_call, skipping")
|
||||
return events
|
||||
|
||||
func_name = func_call.name or ""
|
||||
func_call_id = func_call.call_id
|
||||
|
||||
if predictive_handler and func_name:
|
||||
parsed_args = func_call.parse_arguments()
|
||||
result = predictive_handler.extract_state_value(func_name, parsed_args)
|
||||
if result:
|
||||
state_key, state_value = result
|
||||
flow.current_state[state_key] = state_value
|
||||
events.append(StateSnapshotEvent(snapshot=flow.current_state))
|
||||
|
||||
if func_call_id:
|
||||
events.append(ToolCallEndEvent(tool_call_id=func_call_id))
|
||||
flow.tool_calls_ended.add(func_call_id)
|
||||
|
||||
events.append(
|
||||
CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
"id": content.id,
|
||||
"function_call": {
|
||||
"call_id": func_call_id,
|
||||
"name": func_name,
|
||||
"arguments": make_json_safe(func_call.parse_arguments()),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
interrupt_id = func_call_id or content.id
|
||||
if interrupt_id:
|
||||
flow.interrupts = [
|
||||
{
|
||||
"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()),
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
if require_confirmation:
|
||||
confirm_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=confirm_id,
|
||||
tool_call_name="confirm_changes",
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
args: dict[str, Any] = {
|
||||
"function_name": func_name,
|
||||
"function_call_id": func_call_id,
|
||||
"function_arguments": make_json_safe(func_call.parse_arguments()) or {},
|
||||
"steps": [{"description": f"Execute {func_name}", "status": "enabled"}],
|
||||
}
|
||||
args_json = json.dumps(args)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=confirm_id, delta=args_json))
|
||||
events.append(ToolCallEndEvent(tool_call_id=confirm_id))
|
||||
|
||||
confirm_entry = {
|
||||
"id": confirm_id,
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": args_json},
|
||||
}
|
||||
flow.pending_tool_calls.append(confirm_entry)
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id)
|
||||
|
||||
flow.waiting_for_approval = True
|
||||
return events
|
||||
|
||||
|
||||
def _emit_usage(content: Content) -> list[BaseEvent]:
|
||||
"""Emit usage details as a protocol-level custom event."""
|
||||
usage_details = make_json_safe(content.usage_details or {})
|
||||
return [CustomEvent(name="usage", value=usage_details)]
|
||||
|
||||
|
||||
def _emit_content(
|
||||
content: Any,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
skip_text: bool = False,
|
||||
require_confirmation: bool = True,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit appropriate events for any content type."""
|
||||
content_type = getattr(content, "type", None)
|
||||
if content_type == "text":
|
||||
return _emit_text(content, flow, skip_text)
|
||||
if content_type == "function_call":
|
||||
return _emit_tool_call(content, flow, predictive_handler)
|
||||
if content_type == "function_result":
|
||||
return _emit_tool_result(content, flow, predictive_handler)
|
||||
if content_type == "function_approval_request":
|
||||
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
|
||||
if content_type == "usage":
|
||||
return _emit_usage(content)
|
||||
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
|
||||
return []
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
from typing import Any, Generic
|
||||
|
||||
from agent_framework import ChatOptions
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
@@ -53,10 +53,12 @@ class AGUIRequest(BaseModel):
|
||||
)
|
||||
run_id: str | None = Field(
|
||||
None,
|
||||
validation_alias=AliasChoices("run_id", "runId"),
|
||||
description="Optional run identifier for tracking",
|
||||
)
|
||||
thread_id: str | None = Field(
|
||||
None,
|
||||
validation_alias=AliasChoices("thread_id", "threadId"),
|
||||
description="Optional thread identifier for conversation context",
|
||||
)
|
||||
state: dict[str, Any] | None = Field(
|
||||
@@ -73,12 +75,23 @@ class AGUIRequest(BaseModel):
|
||||
)
|
||||
forwarded_props: dict[str, Any] | None = Field(
|
||||
None,
|
||||
validation_alias=AliasChoices("forwarded_props", "forwardedProps"),
|
||||
description="Additional properties forwarded to the agent",
|
||||
)
|
||||
parent_run_id: str | None = Field(
|
||||
None,
|
||||
validation_alias=AliasChoices("parent_run_id", "parentRunId"),
|
||||
description="ID of the run that spawned this run",
|
||||
)
|
||||
available_interrupts: list[dict[str, Any]] | None = Field(
|
||||
None,
|
||||
validation_alias=AliasChoices("availableInterrupts", "available_interrupts"),
|
||||
description="List of interrupts that can be resumed by the server",
|
||||
)
|
||||
resume: dict[str, Any] | None = Field(
|
||||
None,
|
||||
description="Resume payload containing interrupt responses",
|
||||
)
|
||||
|
||||
|
||||
# region AG-UI Chat Options TypedDict
|
||||
@@ -140,6 +153,12 @@ 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."""
|
||||
|
||||
resume: dict[str, Any]
|
||||
"""Interrupt resume payload to continue a paused run."""
|
||||
|
||||
# ChatOptions fields not applicable for AG-UI
|
||||
store: None # type: ignore[misc]
|
||||
"""Not applicable for AG-UI protocol."""
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Workflow wrapper for AG-UI protocol compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import Workflow
|
||||
|
||||
from ._workflow_run import run_workflow_stream
|
||||
|
||||
WorkflowFactory = Callable[[str], Workflow]
|
||||
|
||||
|
||||
class AgentFrameworkWorkflow:
|
||||
"""Base AG-UI workflow wrapper.
|
||||
|
||||
Can wrap a native ``Workflow`` or be subclassed for custom ``run`` behavior.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: Workflow | None = None,
|
||||
*,
|
||||
workflow_factory: WorkflowFactory | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
if workflow is not None and workflow_factory is not None:
|
||||
raise ValueError("Pass either workflow= or workflow_factory=, not both.")
|
||||
|
||||
self.workflow = workflow
|
||||
self._workflow_factory = workflow_factory
|
||||
self._workflow_by_thread: dict[str, Workflow] = {}
|
||||
self.name = name if name is not None else getattr(workflow, "name", "workflow")
|
||||
self.description = description if description is not None else getattr(workflow, "description", "")
|
||||
|
||||
@staticmethod
|
||||
def _thread_id_from_input(input_data: dict[str, Any]) -> str:
|
||||
"""Resolve a stable thread id from AG-UI input payload."""
|
||||
thread_id = input_data.get("thread_id") or input_data.get("threadId")
|
||||
if thread_id is not None:
|
||||
return str(thread_id)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _resolve_workflow(self, thread_id: str) -> Workflow:
|
||||
"""Get the workflow instance for the current run."""
|
||||
if self.workflow is not None:
|
||||
return self.workflow
|
||||
|
||||
if self._workflow_factory is None:
|
||||
raise NotImplementedError("No workflow is attached. Override run or pass workflow=/workflow_factory=.")
|
||||
|
||||
workflow = self._workflow_by_thread.get(thread_id)
|
||||
if workflow is None:
|
||||
workflow = self._workflow_factory(thread_id)
|
||||
if not isinstance(workflow, Workflow):
|
||||
raise TypeError("workflow_factory must return a Workflow instance.")
|
||||
self._workflow_by_thread[thread_id] = workflow
|
||||
return workflow
|
||||
|
||||
def clear_thread_workflow(self, thread_id: str) -> None:
|
||||
"""Drop a single cached thread workflow instance."""
|
||||
self._workflow_by_thread.pop(thread_id, None)
|
||||
|
||||
def clear_workflow_cache(self) -> None:
|
||||
"""Drop all cached thread workflow instances."""
|
||||
self._workflow_by_thread.clear()
|
||||
|
||||
async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]:
|
||||
"""Run the wrapped workflow and yield AG-UI events.
|
||||
|
||||
Subclasses may override this to provide custom AG-UI streams.
|
||||
"""
|
||||
thread_id = self._thread_id_from_input(input_data)
|
||||
workflow = self._resolve_workflow(thread_id)
|
||||
async for event in run_workflow_stream(input_data, workflow):
|
||||
yield event
|
||||
@@ -0,0 +1,727 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Native AG-UI orchestration for MAF Workflow streams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, cast, get_args, get_origin
|
||||
|
||||
from ag_ui.core import (
|
||||
ActivitySnapshotEvent,
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
RunErrorEvent,
|
||||
RunStartedEvent,
|
||||
StepFinishedEvent,
|
||||
StepStartedEvent,
|
||||
TextMessageEndEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, Workflow, WorkflowRunState
|
||||
|
||||
from ._message_adapters import normalize_agui_input_messages
|
||||
from ._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event,
|
||||
_emit_content,
|
||||
_extract_resume_payload,
|
||||
_normalize_resume_interrupts,
|
||||
)
|
||||
from ._utils import generate_event_id, make_json_safe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_TERMINAL_STATES: set[str] = {
|
||||
WorkflowRunState.IDLE.value,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS.value,
|
||||
WorkflowRunState.CANCELLED.value,
|
||||
}
|
||||
|
||||
_WORKFLOW_EVENT_BASE_FIELDS: set[str] = {
|
||||
"type",
|
||||
"data",
|
||||
"origin",
|
||||
"state",
|
||||
"details",
|
||||
"executor_id",
|
||||
"_request_id",
|
||||
"_source_executor_id",
|
||||
"_request_type",
|
||||
"_response_type",
|
||||
"iteration",
|
||||
}
|
||||
|
||||
_INTERRUPT_CARD_EVENT_NAME = "WorkflowInterruptEvent"
|
||||
|
||||
|
||||
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)
|
||||
if runner_context is None:
|
||||
return {}
|
||||
|
||||
get_pending = getattr(runner_context, "get_pending_request_info_events", None)
|
||||
if get_pending is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
pending = await get_pending()
|
||||
except Exception: # pragma: no cover - defensive for internal API drift
|
||||
logger.warning("Could not read pending workflow requests", exc_info=True)
|
||||
return {}
|
||||
|
||||
if isinstance(pending, dict):
|
||||
return cast(dict[str, Any], pending)
|
||||
return {}
|
||||
|
||||
|
||||
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:
|
||||
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}
|
||||
|
||||
|
||||
def _interrupts_from_pending_requests(pending_events: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Convert pending workflow request events into AG-UI interrupt descriptors."""
|
||||
interrupts: list[dict[str, Any]] = []
|
||||
for request_event in pending_events.values():
|
||||
entry = _interrupt_entry_for_request_event(request_event)
|
||||
if entry is not None:
|
||||
interrupts.append(entry)
|
||||
return interrupts
|
||||
|
||||
|
||||
def _request_payload_from_request_event(request_event: Any) -> dict[str, Any] | None:
|
||||
"""Build the normalized request_info payload from a workflow request event."""
|
||||
request_id = getattr(request_event, "request_id", None)
|
||||
if not request_id:
|
||||
return None
|
||||
|
||||
request_type = getattr(request_event, "request_type", None)
|
||||
response_type = getattr(request_event, "response_type", None)
|
||||
request_data = make_json_safe(getattr(request_event, "data", None))
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"source_executor_id": getattr(request_event, "source_executor_id", None),
|
||||
"request_type": getattr(request_type, "__name__", str(request_type) if request_type else None),
|
||||
"response_type": getattr(response_type, "__name__", str(response_type) if response_type else None),
|
||||
"data": request_data,
|
||||
}
|
||||
|
||||
|
||||
def _extract_responses_from_messages(messages: list[Message]) -> dict[str, Any]:
|
||||
"""Extract request-info responses from incoming tool/function-result messages."""
|
||||
responses: dict[str, Any] = {}
|
||||
for message in messages:
|
||||
for content in message.contents:
|
||||
if content.type != "function_result" or not content.call_id:
|
||||
continue
|
||||
value = _coerce_json_value(content.result)
|
||||
responses[str(content.call_id)] = value
|
||||
return responses
|
||||
|
||||
|
||||
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):
|
||||
value = _coerce_json_value(interrupt.get("value"))
|
||||
responses[str(interrupt["id"])] = value
|
||||
return responses
|
||||
|
||||
|
||||
def _coerce_json_value(value: Any) -> Any:
|
||||
"""Parse JSON strings when possible; otherwise return the original value."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return value
|
||||
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _response_type_name(request_event: Any) -> str:
|
||||
"""Return a stable string name for a request's expected response type."""
|
||||
response_type = getattr(request_event, "response_type", None)
|
||||
if response_type is None:
|
||||
return "unknown"
|
||||
return getattr(response_type, "__name__", str(response_type))
|
||||
|
||||
|
||||
def _coerce_content(value: Any) -> Content | None:
|
||||
"""Best-effort conversion of JSON-like payloads into Content."""
|
||||
if isinstance(value, Content):
|
||||
return value
|
||||
|
||||
candidate = _coerce_json_value(value)
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
|
||||
content_payload = dict(candidate)
|
||||
if "type" not in content_payload and {"approved", "id", "function_call"}.issubset(content_payload):
|
||||
content_payload["type"] = "function_approval_response"
|
||||
|
||||
try:
|
||||
return Content.from_dict(content_payload)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_message_content(content_payload: Any) -> Content | None:
|
||||
"""Best-effort conversion of AG-UI message content items into Content."""
|
||||
if isinstance(content_payload, Content):
|
||||
return content_payload
|
||||
if isinstance(content_payload, str):
|
||||
return Content.from_text(text=content_payload)
|
||||
if isinstance(content_payload, dict):
|
||||
content_dict = dict(content_payload)
|
||||
if content_dict.get("type") == "text":
|
||||
if isinstance(content_dict.get("text"), str):
|
||||
return Content.from_text(text=cast(str, content_dict["text"]))
|
||||
if isinstance(content_dict.get("content"), str):
|
||||
return Content.from_text(text=cast(str, content_dict["content"]))
|
||||
try:
|
||||
return Content.from_dict(content_dict)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_message(value: Any) -> Message | None:
|
||||
"""Best-effort conversion of JSON-like payloads into Message."""
|
||||
if isinstance(value, Message):
|
||||
return value
|
||||
|
||||
candidate = _coerce_json_value(value)
|
||||
if isinstance(candidate, str):
|
||||
return Message(role="user", contents=[Content.from_text(text=candidate)])
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
|
||||
role = str(candidate.get("role") or "user")
|
||||
author_name = candidate.get("author_name") or candidate.get("authorName")
|
||||
message_id = candidate.get("message_id") or candidate.get("messageId")
|
||||
|
||||
contents_payload = candidate.get("contents")
|
||||
if contents_payload is None and "content" in candidate:
|
||||
contents_payload = candidate.get("content")
|
||||
|
||||
normalized_contents: list[Content] = []
|
||||
if isinstance(contents_payload, list):
|
||||
for item in contents_payload:
|
||||
parsed_content = _coerce_message_content(item)
|
||||
if parsed_content is None:
|
||||
return None
|
||||
normalized_contents.append(parsed_content)
|
||||
elif contents_payload is not None:
|
||||
parsed_content = _coerce_message_content(contents_payload)
|
||||
if parsed_content is None:
|
||||
return None
|
||||
normalized_contents.append(parsed_content)
|
||||
else:
|
||||
normalized_contents.append(Content.from_text(text=""))
|
||||
|
||||
return Message(
|
||||
role=role,
|
||||
contents=normalized_contents,
|
||||
author_name=str(author_name) if isinstance(author_name, str) else None,
|
||||
message_id=str(message_id) if isinstance(message_id, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_response_for_request(request_event: Any, value: Any) -> Any | None:
|
||||
"""Coerce a candidate value into the request's expected response type."""
|
||||
response_type = getattr(request_event, "response_type", None)
|
||||
candidate = _coerce_json_value(value)
|
||||
|
||||
if response_type is None:
|
||||
return candidate
|
||||
|
||||
target_type = get_origin(response_type) or response_type
|
||||
if target_type is Any:
|
||||
return candidate
|
||||
if target_type is dict:
|
||||
return candidate if isinstance(candidate, dict) else None
|
||||
if target_type is list:
|
||||
if not isinstance(candidate, list):
|
||||
return None
|
||||
item_types = get_args(response_type)
|
||||
if not item_types:
|
||||
return candidate
|
||||
item_type = get_origin(item_types[0]) or item_types[0]
|
||||
if item_type is Message:
|
||||
converted_messages: list[Message] = []
|
||||
for item in candidate:
|
||||
message = _coerce_message(item)
|
||||
if message is None:
|
||||
return None
|
||||
converted_messages.append(message)
|
||||
return converted_messages
|
||||
if item_type is Content:
|
||||
converted_contents: list[Content] = []
|
||||
for item in candidate:
|
||||
content = _coerce_content(item)
|
||||
if content is None:
|
||||
return None
|
||||
converted_contents.append(content)
|
||||
return converted_contents
|
||||
return candidate
|
||||
if target_type is str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(candidate, str):
|
||||
return candidate
|
||||
return json.dumps(make_json_safe(candidate))
|
||||
if target_type is Message:
|
||||
return _coerce_message(candidate)
|
||||
if target_type is Content:
|
||||
return _coerce_content(candidate)
|
||||
if target_type is bool:
|
||||
return candidate if isinstance(candidate, bool) else None
|
||||
if target_type is int:
|
||||
return candidate if isinstance(candidate, int) and not isinstance(candidate, bool) else None
|
||||
if target_type is float:
|
||||
return candidate if isinstance(candidate, (int, float)) and not isinstance(candidate, bool) else None
|
||||
if isinstance(target_type, type):
|
||||
return candidate if isinstance(candidate, target_type) else None
|
||||
|
||||
# Unknown typing metadata: preserve value as-is.
|
||||
return candidate
|
||||
|
||||
|
||||
def _single_pending_response_from_value(pending_events: dict[str, Any], value: Any) -> dict[str, Any]:
|
||||
"""Map a scalar resume payload to the single pending request (if unambiguous)."""
|
||||
if value is None or len(pending_events) != 1:
|
||||
return {}
|
||||
|
||||
request_event = next(iter(pending_events.values()))
|
||||
request_id = getattr(request_event, "request_id", None)
|
||||
if not request_id:
|
||||
return {}
|
||||
|
||||
coerced_value = _coerce_response_for_request(request_event, value)
|
||||
if coerced_value is None:
|
||||
logger.info(
|
||||
"Ignoring pending request response for request_id=%s: expected %s",
|
||||
request_id,
|
||||
_response_type_name(request_event),
|
||||
)
|
||||
return {}
|
||||
|
||||
return {str(request_id): coerced_value}
|
||||
|
||||
|
||||
def _coerce_responses_for_pending_requests(
|
||||
responses: dict[str, Any],
|
||||
pending_events: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Coerce resume responses to the expected types for known pending requests."""
|
||||
if not responses or not pending_events:
|
||||
return responses
|
||||
|
||||
normalized: dict[str, Any] = {}
|
||||
pending_by_id = {str(request_id): event for request_id, event in pending_events.items()}
|
||||
|
||||
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:
|
||||
logger.info(
|
||||
"Ignoring resume response for request_id=%s: expected %s",
|
||||
request_key,
|
||||
_response_type_name(request_event),
|
||||
)
|
||||
continue
|
||||
normalized[request_key] = coerced_value
|
||||
return normalized
|
||||
|
||||
|
||||
def _latest_user_text(messages: list[Message]) -> str | None:
|
||||
"""Get the most recent user text message, if present."""
|
||||
for message in reversed(messages):
|
||||
role_field = message.role
|
||||
if isinstance(role_field, str):
|
||||
role = role_field
|
||||
else:
|
||||
role = str(getattr(role_field, "value", role_field))
|
||||
if role != "user":
|
||||
continue
|
||||
for content in reversed(message.contents):
|
||||
if content.type != "text":
|
||||
continue
|
||||
text_value = getattr(content, "text", None)
|
||||
if isinstance(text_value, str) and text_value.strip():
|
||||
return text_value
|
||||
return None
|
||||
|
||||
|
||||
def _workflow_interrupt_event_value(request_payload: dict[str, Any]) -> str | None:
|
||||
"""Build a string payload for interrupt-card custom events."""
|
||||
request_data = request_payload.get("data")
|
||||
if request_data is None:
|
||||
return None
|
||||
if isinstance(request_data, str):
|
||||
return request_data
|
||||
return json.dumps(make_json_safe(request_data))
|
||||
|
||||
|
||||
def _message_role_value(message: Message) -> str:
|
||||
"""Normalize Message.role to its string value."""
|
||||
role = message.role
|
||||
if isinstance(role, str):
|
||||
return role
|
||||
return str(getattr(role, "value", role))
|
||||
|
||||
|
||||
def _latest_assistant_contents(messages: list[Message]) -> list[Content] | None:
|
||||
"""Return contents from the most recent assistant message."""
|
||||
for message in reversed(messages):
|
||||
if _message_role_value(message) != "assistant":
|
||||
continue
|
||||
contents = list(message.contents or [])
|
||||
if contents:
|
||||
return contents
|
||||
return None
|
||||
|
||||
|
||||
def _text_from_contents(contents: list[Content]) -> str | None:
|
||||
"""Return normalized assistant text from a content list when present."""
|
||||
text_parts: list[str] = []
|
||||
for content in contents:
|
||||
if content.type != "text":
|
||||
continue
|
||||
text_value = getattr(content, "text", None)
|
||||
if not isinstance(text_value, str):
|
||||
continue
|
||||
if not text_value:
|
||||
continue
|
||||
text_parts.append(text_value)
|
||||
if not text_parts:
|
||||
return None
|
||||
return "".join(text_parts).strip() or None
|
||||
|
||||
|
||||
def _workflow_payload_to_contents(payload: Any) -> list[Content] | None:
|
||||
"""Best-effort conversion from workflow payloads to chat content fragments."""
|
||||
if payload is None:
|
||||
return None
|
||||
if isinstance(payload, Content):
|
||||
return [payload]
|
||||
if isinstance(payload, str):
|
||||
return [Content.from_text(text=payload)]
|
||||
if isinstance(payload, Message):
|
||||
if _message_role_value(payload) != "assistant":
|
||||
return None
|
||||
return list(payload.contents or [])
|
||||
if isinstance(payload, AgentResponseUpdate):
|
||||
role_field = payload.role
|
||||
if role_field is None:
|
||||
return None
|
||||
if isinstance(role_field, str):
|
||||
role = role_field
|
||||
else:
|
||||
role = str(getattr(role_field, "value", role_field))
|
||||
if role != "assistant":
|
||||
return None
|
||||
return list(payload.contents or [])
|
||||
if isinstance(payload, AgentResponse):
|
||||
return _latest_assistant_contents(list(payload.messages or []))
|
||||
if isinstance(payload, list):
|
||||
if payload and all(isinstance(item, Message) for item in payload):
|
||||
return _latest_assistant_contents(cast(list[Message], payload))
|
||||
contents: list[Content] = []
|
||||
for item in payload:
|
||||
item_contents = _workflow_payload_to_contents(item)
|
||||
if item_contents is None:
|
||||
return None
|
||||
contents.extend(item_contents)
|
||||
return contents if contents else None
|
||||
return None
|
||||
|
||||
|
||||
def _event_name(event: Any) -> str:
|
||||
event_type = getattr(event, "type", None)
|
||||
if isinstance(event_type, str) and event_type:
|
||||
return event_type
|
||||
return type(event).__name__
|
||||
|
||||
|
||||
def _custom_event_value(event: Any) -> Any:
|
||||
if getattr(event, "data", None) is not None:
|
||||
return make_json_safe(getattr(event, "data"))
|
||||
|
||||
event_dict = cast(dict[str, Any], getattr(event, "__dict__", {}) or {})
|
||||
custom_fields = {
|
||||
key: make_json_safe(value)
|
||||
for key, value in event_dict.items()
|
||||
if key not in _WORKFLOW_EVENT_BASE_FIELDS and not key.startswith("_")
|
||||
}
|
||||
return custom_fields if custom_fields else None
|
||||
|
||||
|
||||
def _details_message(details: Any) -> str:
|
||||
if details is None:
|
||||
return "Workflow execution failed."
|
||||
if hasattr(details, "message"):
|
||||
message = getattr(details, "message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
return str(details)
|
||||
|
||||
|
||||
def _details_code(details: Any) -> str | None:
|
||||
if details is None:
|
||||
return None
|
||||
if hasattr(details, "error_type"):
|
||||
error_type = getattr(details, "error_type")
|
||||
if isinstance(error_type, str) and error_type:
|
||||
return error_type
|
||||
return None
|
||||
|
||||
|
||||
async def run_workflow_stream(
|
||||
input_data: dict[str, Any],
|
||||
workflow: Workflow,
|
||||
) -> AsyncGenerator[BaseEvent]:
|
||||
"""Run a Workflow and emit AG-UI protocol events."""
|
||||
thread_id = input_data.get("thread_id") or input_data.get("threadId") or str(uuid.uuid4())
|
||||
run_id = input_data.get("run_id") or input_data.get("runId") or str(uuid.uuid4())
|
||||
available_interrupts = input_data.get("available_interrupts") or input_data.get("availableInterrupts")
|
||||
if available_interrupts:
|
||||
logger.debug("Received available interrupts metadata: %s", available_interrupts)
|
||||
|
||||
raw_messages = list(cast(list[dict[str, Any]], input_data.get("messages", []) or []))
|
||||
messages, _ = normalize_agui_input_messages(raw_messages, sanitize_tool_history=False)
|
||||
|
||||
flow = FlowState()
|
||||
interrupts: list[dict[str, Any]] = []
|
||||
run_started_emitted = False
|
||||
terminal_emitted = False
|
||||
run_error_emitted = False
|
||||
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_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)
|
||||
for request_event in pending_before_run.values():
|
||||
request_payload = _request_payload_from_request_event(request_event)
|
||||
if request_payload is None:
|
||||
continue
|
||||
request_id = str(request_payload["request_id"])
|
||||
yield ToolCallStartEvent(tool_call_id=request_id, tool_call_name="request_info")
|
||||
yield ToolCallArgsEvent(tool_call_id=request_id, delta=json.dumps(request_payload))
|
||||
yield ToolCallEndEvent(tool_call_id=request_id)
|
||||
yield CustomEvent(name="request_info", value=request_payload)
|
||||
interrupt_event_value = _workflow_interrupt_event_value(request_payload)
|
||||
if interrupt_event_value is not None:
|
||||
yield CustomEvent(name=_INTERRUPT_CARD_EVENT_NAME, value=interrupt_event_value)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=pending_interrupts)
|
||||
return
|
||||
|
||||
if not responses and not messages:
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=pending_interrupts)
|
||||
return
|
||||
|
||||
def _drain_open_message() -> list[TextMessageEndEvent]:
|
||||
"""Close any open assistant text message and clear flow state."""
|
||||
if not flow.message_id:
|
||||
return []
|
||||
current_message_id = flow.message_id
|
||||
flow.message_id = None
|
||||
flow.accumulated_text = ""
|
||||
return [TextMessageEndEvent(message_id=current_message_id)]
|
||||
|
||||
try:
|
||||
if responses:
|
||||
event_stream = workflow.run(responses=responses, stream=True)
|
||||
else:
|
||||
event_stream = workflow.run(message=messages, stream=True)
|
||||
|
||||
async for event in event_stream:
|
||||
event_type = getattr(event, "type", None)
|
||||
|
||||
if event_type == "started":
|
||||
if not run_started_emitted:
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
run_started_emitted = True
|
||||
continue
|
||||
|
||||
if not run_started_emitted:
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
run_started_emitted = True
|
||||
|
||||
if event_type == "failed":
|
||||
details = getattr(event, "details", None)
|
||||
yield RunErrorEvent(message=_details_message(details), code=_details_code(details))
|
||||
run_error_emitted = True
|
||||
terminal_emitted = True
|
||||
continue
|
||||
|
||||
if event_type == "status":
|
||||
state = getattr(event, "state", None)
|
||||
if isinstance(state, str):
|
||||
state_value = state
|
||||
else:
|
||||
state_value = str(getattr(state, "value", state))
|
||||
if state_value in _TERMINAL_STATES and not terminal_emitted:
|
||||
if not interrupts:
|
||||
interrupts.extend(_interrupts_from_pending_requests(await _pending_request_events(workflow)))
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=interrupts)
|
||||
terminal_emitted = True
|
||||
elif state_value not in _TERMINAL_STATES:
|
||||
yield CustomEvent(name="status", value={"state": state_value})
|
||||
continue
|
||||
|
||||
if event_type == "superstep_started":
|
||||
for end_event in _drain_open_message():
|
||||
yield end_event
|
||||
iteration = getattr(event, "iteration", None)
|
||||
yield StepStartedEvent(step_name=f"superstep:{iteration}")
|
||||
continue
|
||||
|
||||
if event_type == "superstep_completed":
|
||||
iteration = getattr(event, "iteration", None)
|
||||
yield StepFinishedEvent(step_name=f"superstep:{iteration}")
|
||||
continue
|
||||
|
||||
if event_type in {"executor_invoked", "executor_completed", "executor_failed"}:
|
||||
executor_id = getattr(event, "executor_id", None)
|
||||
status = {
|
||||
"executor_invoked": "in_progress",
|
||||
"executor_completed": "completed",
|
||||
"executor_failed": "failed",
|
||||
}[event_type]
|
||||
if isinstance(executor_id, str) and executor_id:
|
||||
if event_type == "executor_invoked":
|
||||
for end_event in _drain_open_message():
|
||||
yield end_event
|
||||
yield StepStartedEvent(step_name=executor_id)
|
||||
else:
|
||||
yield StepFinishedEvent(step_name=executor_id)
|
||||
executor_payload: dict[str, Any] = {
|
||||
"executor_id": executor_id,
|
||||
"status": status,
|
||||
}
|
||||
if event_type == "executor_failed":
|
||||
executor_payload["details"] = make_json_safe(getattr(event, "details", None))
|
||||
else:
|
||||
executor_payload["data"] = make_json_safe(getattr(event, "data", None))
|
||||
|
||||
yield ActivitySnapshotEvent(
|
||||
message_id=f"executor:{executor_id}" if executor_id else generate_event_id(),
|
||||
activity_type="executor",
|
||||
content=executor_payload,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == "request_info":
|
||||
for end_event in _drain_open_message():
|
||||
yield end_event
|
||||
request_payload = _request_payload_from_request_event(event)
|
||||
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})
|
||||
args_delta = json.dumps(request_payload)
|
||||
|
||||
yield ToolCallStartEvent(tool_call_id=str(request_id), tool_call_name="request_info")
|
||||
yield ToolCallArgsEvent(tool_call_id=str(request_id), delta=args_delta)
|
||||
yield ToolCallEndEvent(tool_call_id=str(request_id))
|
||||
yield CustomEvent(name="request_info", value=request_payload)
|
||||
interrupt_event_value = _workflow_interrupt_event_value(request_payload)
|
||||
if interrupt_event_value is not None:
|
||||
yield CustomEvent(name=_INTERRUPT_CARD_EVENT_NAME, value=interrupt_event_value)
|
||||
continue
|
||||
|
||||
if event_type in {"output", "data"}:
|
||||
output_payload = getattr(event, "data", None)
|
||||
if isinstance(output_payload, BaseEvent):
|
||||
yield output_payload
|
||||
continue
|
||||
if (
|
||||
isinstance(output_payload, list)
|
||||
and output_payload
|
||||
and all(isinstance(item, BaseEvent) for item in output_payload)
|
||||
):
|
||||
for item in output_payload:
|
||||
yield item
|
||||
continue
|
||||
contents = _workflow_payload_to_contents(output_payload)
|
||||
if contents:
|
||||
output_text = _text_from_contents(contents)
|
||||
if output_text and output_text == last_assistant_text:
|
||||
continue
|
||||
for content in contents:
|
||||
for out_event in _emit_content(content, flow, predictive_handler=None, skip_text=False):
|
||||
yield out_event
|
||||
if flow.message_id and flow.accumulated_text:
|
||||
last_assistant_text = flow.accumulated_text.strip() or last_assistant_text
|
||||
elif output_text:
|
||||
last_assistant_text = output_text
|
||||
else:
|
||||
yield CustomEvent(name="workflow_output", value=make_json_safe(output_payload))
|
||||
continue
|
||||
|
||||
# Fall back to custom events for diagnostics, orchestration events, and custom workflow events.
|
||||
yield CustomEvent(name=_event_name(event), value=_custom_event_value(event))
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Workflow AG-UI stream failed: %s", exc)
|
||||
if not run_started_emitted:
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
run_started_emitted = True
|
||||
if not run_error_emitted:
|
||||
yield RunErrorEvent(message=str(exc), code=type(exc).__name__)
|
||||
run_error_emitted = True
|
||||
terminal_emitted = True
|
||||
|
||||
for end_event in _drain_open_message():
|
||||
yield end_event
|
||||
|
||||
if not run_started_emitted:
|
||||
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
|
||||
|
||||
if not terminal_emitted and not run_error_emitted:
|
||||
if not interrupts:
|
||||
interrupts.extend(_interrupts_from_pending_requests(await _pending_request_events(workflow)))
|
||||
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=interrupts)
|
||||
@@ -85,6 +85,7 @@ Complete examples for all AG-UI features are available:
|
||||
- `document_writer_agent(client)` - Predictive state updates (Feature 7)
|
||||
- `research_assistant_agent(client)` - Research with progress events
|
||||
- `task_planner_agent(client)` - Task planning with approvals
|
||||
- `subgraphs_agent()` - Deterministic travel-planning subgraphs flow (Dojo `subgraphs` feature)
|
||||
|
||||
### Using Example Agents
|
||||
|
||||
@@ -130,6 +131,7 @@ The server exposes endpoints at:
|
||||
- `/tool_based_generative_ui` - Custom UI components with `ui_generator_agent`
|
||||
- `/shared_state` - Recipe management with `recipe_agent`
|
||||
- `/predictive_state_updates` - Document writing with `document_writer_agent`
|
||||
- `/subgraphs` - Travel planner with interrupt-driven flight/hotel choices via `subgraphs_agent`
|
||||
|
||||
### Complete FastAPI Example
|
||||
|
||||
@@ -145,6 +147,7 @@ from agent_framework_ag_ui_examples.agents import (
|
||||
ui_generator_agent,
|
||||
recipe_agent,
|
||||
document_writer_agent,
|
||||
subgraphs_agent,
|
||||
)
|
||||
|
||||
app = FastAPI(title="AG-UI Examples")
|
||||
@@ -160,6 +163,7 @@ add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(client), "/ag
|
||||
add_agent_framework_fastapi_endpoint(app, ui_generator_agent(client), "/tool_based_generative_ui")
|
||||
add_agent_framework_fastapi_endpoint(app, recipe_agent(client), "/shared_state")
|
||||
add_agent_framework_fastapi_endpoint(app, document_writer_agent(client), "/predictive_state_updates")
|
||||
add_agent_framework_fastapi_endpoint(app, subgraphs_agent(), "/subgraphs")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -7,6 +7,7 @@ from .human_in_the_loop_agent import human_in_the_loop_agent
|
||||
from .recipe_agent import recipe_agent
|
||||
from .research_assistant_agent import research_assistant_agent
|
||||
from .simple_agent import simple_agent
|
||||
from .subgraphs_agent import subgraphs_agent
|
||||
from .task_planner_agent import task_planner_agent
|
||||
from .task_steps_agent import task_steps_agent_wrapped
|
||||
from .ui_generator_agent import ui_generator_agent
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"recipe_agent",
|
||||
"research_assistant_agent",
|
||||
"simple_agent",
|
||||
"subgraphs_agent",
|
||||
"task_planner_agent",
|
||||
"task_steps_agent_wrapped",
|
||||
"ui_generator_agent",
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Subgraphs travel planner built with MAF workflow primitives."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
STATIC_FLIGHTS: list[dict[str, str]] = [
|
||||
{
|
||||
"airline": "KLM",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$650",
|
||||
"duration": "11h 30m",
|
||||
},
|
||||
{
|
||||
"airline": "United",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$720",
|
||||
"duration": "12h 15m",
|
||||
},
|
||||
]
|
||||
|
||||
STATIC_HOTELS: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "Hotel Zephyr",
|
||||
"location": "Fisherman's Wharf",
|
||||
"price_per_night": "$280/night",
|
||||
"rating": "4.2 stars",
|
||||
},
|
||||
{
|
||||
"name": "The Ritz-Carlton",
|
||||
"location": "Nob Hill",
|
||||
"price_per_night": "$550/night",
|
||||
"rating": "4.8 stars",
|
||||
},
|
||||
{
|
||||
"name": "Hotel Zoe",
|
||||
"location": "Union Square",
|
||||
"price_per_night": "$320/night",
|
||||
"rating": "4.4 stars",
|
||||
},
|
||||
]
|
||||
|
||||
STATIC_EXPERIENCES: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "Pier 39",
|
||||
"type": "activity",
|
||||
"description": "Iconic waterfront destination with shops and sea lions",
|
||||
"location": "Fisherman's Wharf",
|
||||
},
|
||||
{
|
||||
"name": "Golden Gate Bridge",
|
||||
"type": "activity",
|
||||
"description": "World-famous suspension bridge with stunning views",
|
||||
"location": "Golden Gate",
|
||||
},
|
||||
{
|
||||
"name": "Swan Oyster Depot",
|
||||
"type": "restaurant",
|
||||
"description": "Historic seafood counter serving fresh oysters",
|
||||
"location": "Polk Street",
|
||||
},
|
||||
{
|
||||
"name": "Tartine Bakery",
|
||||
"type": "restaurant",
|
||||
"description": "Artisanal bakery famous for bread and pastries",
|
||||
"location": "Mission District",
|
||||
},
|
||||
]
|
||||
|
||||
_STATE_KEY = "subgraphs_state"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PresentFlights:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PresentHotels:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PlanExperiences:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FinalizeTrip:
|
||||
pass
|
||||
|
||||
|
||||
def _initial_state() -> dict[str, Any]:
|
||||
return {
|
||||
"itinerary": {},
|
||||
"experiences": [],
|
||||
"flights": [],
|
||||
"hotels": [],
|
||||
"planning_step": "start",
|
||||
"active_agent": "supervisor",
|
||||
}
|
||||
|
||||
|
||||
def _emit_text_events(text: str) -> list[BaseEvent]:
|
||||
message_id = str(uuid.uuid4())
|
||||
return [
|
||||
TextMessageStartEvent(message_id=message_id, role="assistant"),
|
||||
TextMessageContentEvent(message_id=message_id, delta=text),
|
||||
TextMessageEndEvent(message_id=message_id),
|
||||
]
|
||||
|
||||
|
||||
async def _emit_text(ctx: WorkflowContext[Any, BaseEvent], text: str) -> None:
|
||||
for event in _emit_text_events(text):
|
||||
await ctx.yield_output(event)
|
||||
|
||||
|
||||
async def _emit_state_snapshot(ctx: WorkflowContext[Any, BaseEvent], state: dict[str, Any]) -> None:
|
||||
await ctx.yield_output(StateSnapshotEvent(snapshot=deepcopy(state)))
|
||||
|
||||
|
||||
def _flight_interrupt_value() -> dict[str, Any]:
|
||||
return {
|
||||
"message": "Choose the flight you want. I recommend KLM because it is cheaper and usually on time.",
|
||||
"options": deepcopy(STATIC_FLIGHTS),
|
||||
"recommendation": deepcopy(STATIC_FLIGHTS[0]),
|
||||
"agent": "flights",
|
||||
}
|
||||
|
||||
|
||||
def _hotel_interrupt_value() -> dict[str, Any]:
|
||||
return {
|
||||
"message": "Choose your hotel. I recommend Hotel Zoe for the best value in a central location.",
|
||||
"options": deepcopy(STATIC_HOTELS),
|
||||
"recommendation": deepcopy(STATIC_HOTELS[2]),
|
||||
"agent": "hotels",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_flight(value: Any) -> dict[str, str] | None:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(value, dict) and value.get("airline"):
|
||||
return {
|
||||
"airline": str(value.get("airline", "")),
|
||||
"departure": str(value.get("departure", "")),
|
||||
"arrival": str(value.get("arrival", "")),
|
||||
"price": str(value.get("price", "")),
|
||||
"duration": str(value.get("duration", "")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_hotel(value: Any) -> dict[str, str] | None:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(value, dict) and value.get("name"):
|
||||
return {
|
||||
"name": str(value.get("name", "")),
|
||||
"location": str(value.get("location", "")),
|
||||
"price_per_night": str(value.get("price_per_night", "")),
|
||||
"rating": str(value.get("rating", "")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _load_state(ctx: WorkflowContext[Any, BaseEvent]) -> dict[str, Any]:
|
||||
state = ctx.get_state(_STATE_KEY)
|
||||
if isinstance(state, dict):
|
||||
return state
|
||||
new_state = _initial_state()
|
||||
ctx.set_state(_STATE_KEY, new_state)
|
||||
return new_state
|
||||
|
||||
|
||||
class _SupervisorExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="supervisor_agent")
|
||||
|
||||
@handler
|
||||
async def start(self, message: list[Message], ctx: WorkflowContext[_PresentFlights, BaseEvent]) -> None:
|
||||
del message
|
||||
state = _initial_state()
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await _emit_text(
|
||||
ctx,
|
||||
"Supervisor: I will coordinate our specialist agents to plan your San Francisco trip end to end.",
|
||||
)
|
||||
|
||||
state["active_agent"] = "flights"
|
||||
state["planning_step"] = "collecting_flights"
|
||||
state["flights"] = deepcopy(STATIC_FLIGHTS)
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await ctx.send_message(_PresentFlights(), target_id="flights_agent")
|
||||
|
||||
@handler
|
||||
async def finalize(self, message: _FinalizeTrip, ctx: WorkflowContext[Any, BaseEvent]) -> None:
|
||||
del message
|
||||
state = _load_state(ctx)
|
||||
state["active_agent"] = "supervisor"
|
||||
state["planning_step"] = "complete"
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
await _emit_text(ctx, "Supervisor: Your travel planning is complete and your itinerary is ready.")
|
||||
|
||||
|
||||
class _FlightsExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="flights_agent")
|
||||
|
||||
@handler
|
||||
async def present_options(self, message: _PresentFlights, ctx: WorkflowContext[_PresentHotels, BaseEvent]) -> None:
|
||||
del message
|
||||
await _emit_text(
|
||||
ctx,
|
||||
"Flights Agent: I found two flight options from Amsterdam to San Francisco. "
|
||||
"KLM is recommended for the best value and schedule.",
|
||||
)
|
||||
await ctx.request_info(_flight_interrupt_value(), dict, request_id="flights-choice")
|
||||
|
||||
@response_handler
|
||||
async def handle_selection(
|
||||
self,
|
||||
original_request: dict,
|
||||
response: dict,
|
||||
ctx: WorkflowContext[_PresentHotels, BaseEvent],
|
||||
) -> None:
|
||||
del original_request
|
||||
state = _load_state(ctx)
|
||||
selected_flight = _normalize_flight(response)
|
||||
|
||||
if selected_flight is None:
|
||||
state["active_agent"] = "flights"
|
||||
state["planning_step"] = "collecting_flights"
|
||||
state["flights"] = deepcopy(STATIC_FLIGHTS)
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
await _emit_text(ctx, "Flights Agent: Please choose a flight option from the selection card to continue.")
|
||||
await ctx.request_info(_flight_interrupt_value(), dict, request_id="flights-choice")
|
||||
return
|
||||
|
||||
itinerary = state.setdefault("itinerary", {})
|
||||
itinerary["flight"] = selected_flight
|
||||
|
||||
state["active_agent"] = "flights"
|
||||
state["planning_step"] = "booking_flight"
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await _emit_text(
|
||||
ctx,
|
||||
f"Flights Agent: Great choice. I will book the {selected_flight['airline']} flight. "
|
||||
"Now I am routing you to Hotels Agent for accommodation.",
|
||||
)
|
||||
|
||||
state["active_agent"] = "hotels"
|
||||
state["planning_step"] = "collecting_hotels"
|
||||
state["hotels"] = deepcopy(STATIC_HOTELS)
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await ctx.send_message(_PresentHotels(), target_id="hotels_agent")
|
||||
|
||||
|
||||
class _HotelsExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="hotels_agent")
|
||||
|
||||
@handler
|
||||
async def present_options(self, message: _PresentHotels, ctx: WorkflowContext[_PlanExperiences, BaseEvent]) -> None:
|
||||
del message
|
||||
await _emit_text(
|
||||
ctx,
|
||||
"Hotels Agent: I found three accommodation options in San Francisco. "
|
||||
"Hotel Zoe is recommended for the best balance of location, quality, and price.",
|
||||
)
|
||||
await ctx.request_info(_hotel_interrupt_value(), dict, request_id="hotels-choice")
|
||||
|
||||
@response_handler
|
||||
async def handle_selection(
|
||||
self,
|
||||
original_request: dict,
|
||||
response: dict,
|
||||
ctx: WorkflowContext[_PlanExperiences, BaseEvent],
|
||||
) -> None:
|
||||
del original_request
|
||||
state = _load_state(ctx)
|
||||
selected_hotel = _normalize_hotel(response)
|
||||
|
||||
if selected_hotel is None:
|
||||
state["active_agent"] = "hotels"
|
||||
state["planning_step"] = "collecting_hotels"
|
||||
state["hotels"] = deepcopy(STATIC_HOTELS)
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
await _emit_text(ctx, "Hotels Agent: Please choose a hotel option from the selection card to continue.")
|
||||
await ctx.request_info(_hotel_interrupt_value(), dict, request_id="hotels-choice")
|
||||
return
|
||||
|
||||
itinerary = state.setdefault("itinerary", {})
|
||||
itinerary["hotel"] = selected_hotel
|
||||
|
||||
state["active_agent"] = "hotels"
|
||||
state["planning_step"] = "booking_hotel"
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await _emit_text(
|
||||
ctx,
|
||||
f"Hotels Agent: Excellent, {selected_hotel['name']} is booked. "
|
||||
"I am routing you to Experiences Agent for activities and restaurants.",
|
||||
)
|
||||
|
||||
state["active_agent"] = "experiences"
|
||||
state["planning_step"] = "curating_experiences"
|
||||
state["experiences"] = deepcopy(STATIC_EXPERIENCES)
|
||||
ctx.set_state(_STATE_KEY, state)
|
||||
await _emit_state_snapshot(ctx, state)
|
||||
|
||||
await ctx.send_message(_PlanExperiences(), target_id="experiences_agent")
|
||||
|
||||
|
||||
class _ExperiencesExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="experiences_agent")
|
||||
|
||||
@handler
|
||||
async def plan(self, message: _PlanExperiences, ctx: WorkflowContext[_FinalizeTrip, BaseEvent]) -> None:
|
||||
del message
|
||||
await _emit_text(
|
||||
ctx,
|
||||
"Experiences Agent: I planned activities and restaurants including "
|
||||
"Pier 39, Golden Gate Bridge, Swan Oyster Depot, and Tartine Bakery.",
|
||||
)
|
||||
await ctx.send_message(_FinalizeTrip(), target_id="supervisor_agent")
|
||||
|
||||
|
||||
def _build_subgraphs_workflow() -> Workflow:
|
||||
supervisor = _SupervisorExecutor()
|
||||
flights = _FlightsExecutor()
|
||||
hotels = _HotelsExecutor()
|
||||
experiences = _ExperiencesExecutor()
|
||||
|
||||
return (
|
||||
WorkflowBuilder(
|
||||
name="subgraphs",
|
||||
description="Travel planning supervisor with flights/hotels/experiences subgraphs.",
|
||||
start_executor=supervisor,
|
||||
)
|
||||
.add_edge(supervisor, flights)
|
||||
.add_edge(flights, hotels)
|
||||
.add_edge(hotels, experiences)
|
||||
.add_edge(experiences, supervisor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _build_subgraphs_workflow_for_thread(thread_id: str) -> Workflow:
|
||||
"""Create a workflow instance scoped to a single AG-UI thread."""
|
||||
del thread_id
|
||||
return _build_subgraphs_workflow()
|
||||
|
||||
|
||||
def subgraphs_agent() -> AgentFrameworkWorkflow:
|
||||
"""Create the subgraphs travel planner agent."""
|
||||
return AgentFrameworkWorkflow(
|
||||
workflow_factory=_build_subgraphs_workflow_for_thread,
|
||||
name="subgraphs",
|
||||
description="Travel planning workflow with interrupt-driven selections.",
|
||||
)
|
||||
@@ -24,6 +24,8 @@ from agent_framework import Agent, Content, Message, SupportsChatGetResponse, to
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
"""Status of a task step."""
|
||||
@@ -105,38 +107,29 @@ def _create_task_steps_agent(client: SupportsChatGetResponse[Any]) -> AgentFrame
|
||||
|
||||
|
||||
# Wrap the agent's run method to add step execution simulation
|
||||
class TaskStepsAgentWithExecution:
|
||||
class TaskStepsAgentWithExecution(AgentFrameworkWorkflow):
|
||||
"""Wrapper that adds step execution simulation after plan generation.
|
||||
|
||||
This wrapper delegates to AgentFrameworkAgent but is recognized as compatible
|
||||
by add_agent_framework_fastapi_endpoint since it implements run_agent().
|
||||
by add_agent_framework_fastapi_endpoint since it implements run().
|
||||
"""
|
||||
|
||||
def __init__(self, base_agent: AgentFrameworkAgent):
|
||||
"""Initialize wrapper with base agent."""
|
||||
super().__init__(name=base_agent.name, description=base_agent.description)
|
||||
self._base_agent = base_agent
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Delegate to base agent."""
|
||||
return self._base_agent.name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Delegate to base agent."""
|
||||
return self._base_agent.description
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Delegate all other attribute access to base agent."""
|
||||
return getattr(self._base_agent, name)
|
||||
|
||||
async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any]:
|
||||
async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[Any]:
|
||||
"""Run the agent and then simulate step execution."""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("TaskStepsAgentWithExecution.run_agent() called - wrapper is active")
|
||||
logger.info("TaskStepsAgentWithExecution.run() called - wrapper is active")
|
||||
|
||||
# First, run the base agent to generate the plan - buffer text messages
|
||||
final_state: dict[str, Any] = {}
|
||||
@@ -144,7 +137,7 @@ class TaskStepsAgentWithExecution:
|
||||
tool_call_id: str | None = None
|
||||
buffered_text_events: list[Any] = [] # Buffer text from first LLM call
|
||||
|
||||
async for event in self._base_agent.run_agent(input_data):
|
||||
async for event in self._base_agent.run(input_data):
|
||||
event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__
|
||||
logger.info(f"Processing event: {event_type_str}")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..agents.document_writer_agent import document_writer_agent
|
||||
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
|
||||
from ..agents.recipe_agent import recipe_agent
|
||||
from ..agents.simple_agent import simple_agent
|
||||
from ..agents.subgraphs_agent import subgraphs_agent
|
||||
from ..agents.task_steps_agent import task_steps_agent_wrapped
|
||||
from ..agents.ui_generator_agent import ui_generator_agent
|
||||
from ..agents.weather_agent import weather_agent
|
||||
@@ -123,6 +124,13 @@ add_agent_framework_fastapi_endpoint(
|
||||
path="/tool_based_generative_ui",
|
||||
)
|
||||
|
||||
# Subgraphs - deterministic travel planner with interrupt-driven selections
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=subgraphs_agent(),
|
||||
path="/subgraphs",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the server."""
|
||||
|
||||
@@ -356,3 +356,31 @@ class TestAGUIChatClient:
|
||||
response = await client.inner_get_response(messages=messages, options=chat_options)
|
||||
|
||||
assert response is not None
|
||||
|
||||
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"}]
|
||||
resume_payload = {"interrupts": [{"id": "req_1", "value": "approved"}]}
|
||||
|
||||
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") == available_interrupts
|
||||
assert kwargs.get("resume") == resume_payload
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="continue")]
|
||||
options = {
|
||||
"available_interrupts": available_interrupts,
|
||||
"resume": resume_payload,
|
||||
}
|
||||
|
||||
response = await client.inner_get_response(messages=messages, options=options)
|
||||
assert response is not None
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_run_started_event_emission(streaming_chat_client_stub):
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# First event should be RunStartedEvent
|
||||
@@ -131,7 +131,7 @@ async def test_predict_state_custom_event_emission(streaming_chat_client_stub):
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Find PredictState event
|
||||
@@ -144,6 +144,83 @@ async def test_predict_state_custom_event_emission(streaming_chat_client_stub):
|
||||
assert {"state_key": "summary", "tool": "summarize", "tool_argument": "text"} in predict_value
|
||||
|
||||
|
||||
async def test_usage_content_emits_custom_usage_event(streaming_chat_client_stub):
|
||||
"""Usage content from the wrapped agent should be surfaced as a custom usage event."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
del messages, options, kwargs
|
||||
yield ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_usage(
|
||||
{
|
||||
"input_token_count": 10,
|
||||
"output_token_count": 4,
|
||||
"total_token_count": 14,
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
agent = Agent(name="usage_agent", instructions="Usage test", client=streaming_chat_client_stub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run({"messages": [{"role": "user", "content": "Hi"}]}):
|
||||
events.append(event)
|
||||
|
||||
usage_events = [event for event in events if event.type == "CUSTOM" and event.name == "usage"]
|
||||
assert len(usage_events) == 1
|
||||
assert usage_events[0].value["input_token_count"] == 10
|
||||
assert usage_events[0].value["output_token_count"] == 4
|
||||
assert usage_events[0].value["total_token_count"] == 14
|
||||
|
||||
|
||||
async def test_multimodal_input_is_forwarded_to_agent_run(streaming_chat_client_stub):
|
||||
"""Multimodal AG-UI input should be converted and passed through to agent.run."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
captured_messages: list[Message] = []
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
del options, kwargs
|
||||
captured_messages[:] = list(messages)
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Processed multimodal input")])
|
||||
|
||||
agent = Agent(name="multimodal_agent", instructions="Multimodal test", client=streaming_chat_client_stub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": "https://example.com/cat.png", "mimeType": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
_ = [event async for event in wrapper.run(input_data)]
|
||||
|
||||
assert len(captured_messages) == 1
|
||||
message = captured_messages[0]
|
||||
assert message.role == "user"
|
||||
assert len(message.contents) == 2
|
||||
assert message.contents[0].type == "text"
|
||||
assert message.contents[0].text == "What is in this image?"
|
||||
assert message.contents[1].type == "uri"
|
||||
assert message.contents[1].uri == "https://example.com/cat.png"
|
||||
|
||||
|
||||
async def test_initial_state_snapshot_with_schema(streaming_chat_client_stub):
|
||||
"""Test initial StateSnapshotEvent emission when state_schema present."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
@@ -163,7 +240,7 @@ async def test_initial_state_snapshot_with_schema(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Find StateSnapshotEvent
|
||||
@@ -190,7 +267,7 @@ async def test_state_initialization_object_type(streaming_chat_client_stub):
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Find StateSnapshotEvent
|
||||
@@ -217,7 +294,7 @@ async def test_state_initialization_array_type(streaming_chat_client_stub):
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Find StateSnapshotEvent
|
||||
@@ -243,7 +320,7 @@ async def test_run_finished_event_emission(streaming_chat_client_stub):
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Last event should be RunFinishedEvent
|
||||
@@ -280,7 +357,7 @@ async def test_tool_result_confirm_changes_accepted(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit text message confirming acceptance
|
||||
@@ -322,7 +399,7 @@ async def test_tool_result_confirm_changes_rejected(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit text message asking what to change
|
||||
@@ -362,7 +439,7 @@ async def test_tool_result_function_approval_accepted(streaming_chat_client_stub
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should list enabled steps
|
||||
@@ -405,7 +482,7 @@ async def test_tool_result_function_approval_rejected(streaming_chat_client_stub
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should ask what to change about the plan
|
||||
@@ -441,7 +518,7 @@ async def test_thread_metadata_tracking(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# AG-UI internal metadata should NOT be passed to chat client options
|
||||
@@ -479,7 +556,7 @@ async def test_state_context_injection(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Current state should NOT be passed to chat client options
|
||||
@@ -502,7 +579,7 @@ async def test_no_messages_provided(streaming_chat_client_stub):
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit RunStartedEvent and RunFinishedEvent only
|
||||
@@ -526,7 +603,7 @@ async def test_message_end_event_emission(streaming_chat_client_stub):
|
||||
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should have TextMessageEndEvent before RunFinishedEvent
|
||||
@@ -556,7 +633,7 @@ async def test_error_handling_with_exception(streaming_chat_client_stub):
|
||||
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Simulated failure"):
|
||||
async for _ in wrapper.run_agent(input_data):
|
||||
async for _ in wrapper.run(input_data):
|
||||
pass
|
||||
|
||||
|
||||
@@ -586,7 +663,7 @@ async def test_json_decode_error_in_tool_result(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Orphaned tool result should be sanitized out
|
||||
@@ -616,7 +693,7 @@ async def test_agent_with_use_service_session_is_false(streaming_chat_client_stu
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
assert request_service_session_id is None # type: ignore[attr-defined] (service_session_id should be set)
|
||||
|
||||
@@ -643,7 +720,7 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
request_service_session_id = agent.client.last_service_session_id
|
||||
assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set)
|
||||
@@ -714,7 +791,7 @@ async def test_function_approval_mode_executes_tool(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Verify the run completed successfully
|
||||
@@ -802,7 +879,7 @@ async def test_function_approval_mode_rejection(streaming_chat_client_stub):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Verify the run completed
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
"""Tests for FastAPI endpoint creation (_endpoint.py)."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, ChatResponseUpdate, Content
|
||||
from ag_ui.core import RunStartedEvent
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
)
|
||||
from agent_framework.orchestrations import SequentialBuilder
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from fastapi.params import Depends
|
||||
@@ -13,6 +22,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui._agent import AgentFrameworkAgent
|
||||
from agent_framework_ag_ui._workflow import AgentFrameworkWorkflow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -55,6 +65,32 @@ async def test_add_endpoint_with_wrapped_agent(build_chat_client):
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
|
||||
async def test_add_endpoint_with_workflow_protocol():
|
||||
"""Test adding endpoint with native Workflow support."""
|
||||
|
||||
@executor(id="start")
|
||||
async def start(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output("Workflow response")
|
||||
|
||||
app = FastAPI()
|
||||
workflow = WorkflowBuilder(start_executor=start).build()
|
||||
|
||||
add_agent_framework_fastapi_endpoint(app, workflow, path="/workflow")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/workflow", json={"messages": [{"role": "user", "content": "Hello"}]})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
lines = [line for line in content.split("\n") if line.startswith("data: ")]
|
||||
event_types = [json.loads(line[6:]).get("type") for line in lines]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "TEXT_MESSAGE_CONTENT" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
|
||||
async def test_endpoint_with_state_schema(build_chat_client):
|
||||
"""Test endpoint with state_schema parameter."""
|
||||
app = FastAPI()
|
||||
@@ -403,8 +439,32 @@ async def test_endpoint_internal_error_handling(build_chat_client):
|
||||
mock_deepcopy.side_effect = Exception("Simulated internal error")
|
||||
response = client.post("/error-test", json={"messages": [{"role": "user", "content": "Hello"}]})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "An internal error has occurred."}
|
||||
|
||||
|
||||
async def test_endpoint_streaming_error_emits_run_error_event():
|
||||
"""Streaming exceptions should emit RUN_ERROR instead of terminating silently."""
|
||||
|
||||
class FailingStreamWorkflow(AgentFrameworkWorkflow):
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
del input_data
|
||||
yield RunStartedEvent(run_id="run-1", thread_id="thread-1")
|
||||
raise RuntimeError("stream exploded")
|
||||
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, FailingStreamWorkflow(), path="/stream-error")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/stream-error", json={"messages": [{"role": "user", "content": "Hello"}]})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"error": "An internal error has occurred."}
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
lines = [line for line in content.split("\n") if line.startswith("data: ")]
|
||||
event_types = [json.loads(line[6:]).get("type") for line in lines]
|
||||
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_ERROR" in event_types
|
||||
|
||||
|
||||
async def test_endpoint_with_dependencies_blocks_unauthorized(build_chat_client):
|
||||
|
||||
@@ -207,6 +207,26 @@ class TestAGUIEventConverter:
|
||||
assert update.additional_properties["thread_id"] == "thread_123"
|
||||
assert update.additional_properties["run_id"] == "run_456"
|
||||
|
||||
def test_run_finished_event_with_interrupt(self) -> None:
|
||||
"""RUN_FINISHED interrupt metadata is preserved in additional_properties."""
|
||||
converter = AGUIEventConverter()
|
||||
converter.thread_id = "thread_123"
|
||||
converter.run_id = "run_456"
|
||||
|
||||
event = {
|
||||
"type": "RUN_FINISHED",
|
||||
"threadId": "thread_123",
|
||||
"runId": "run_456",
|
||||
"interrupt": [{"id": "req_1", "value": {"question": "Continue?"}}],
|
||||
"result": {"status": "paused"},
|
||||
}
|
||||
|
||||
update = converter.convert_event(event)
|
||||
|
||||
assert update is not None
|
||||
assert update.additional_properties["interrupt"] == [{"id": "req_1", "value": {"question": "Continue?"}}]
|
||||
assert update.additional_properties["result"] == {"status": "paused"}
|
||||
|
||||
def test_run_error_event(self) -> None:
|
||||
"""Test conversion of RUN_ERROR event."""
|
||||
converter = AGUIEventConverter()
|
||||
@@ -239,6 +259,37 @@ class TestAGUIEventConverter:
|
||||
|
||||
assert update is None
|
||||
|
||||
def test_custom_event_conversion(self) -> None:
|
||||
"""CUSTOM events are converted to update metadata."""
|
||||
converter = AGUIEventConverter()
|
||||
event = {
|
||||
"type": "CUSTOM",
|
||||
"name": "progress",
|
||||
"value": {"percent": 10},
|
||||
}
|
||||
|
||||
update = converter.convert_event(event)
|
||||
|
||||
assert update is not None
|
||||
assert update.additional_properties["ag_ui_custom_event"]["name"] == "progress"
|
||||
assert update.additional_properties["ag_ui_custom_event"]["value"] == {"percent": 10}
|
||||
assert update.additional_properties["ag_ui_custom_event"]["raw_type"] == "CUSTOM"
|
||||
|
||||
def test_custom_event_alias_conversion(self) -> None:
|
||||
"""CUSTOM_EVENT/custom_event aliases map to CUSTOM behavior."""
|
||||
converter = AGUIEventConverter()
|
||||
events = [
|
||||
{"type": "CUSTOM_EVENT", "name": "alias_upper", "value": {"v": 1}},
|
||||
{"type": "custom_event", "name": "alias_lower", "value": {"v": 2}},
|
||||
]
|
||||
|
||||
updates = [converter.convert_event(event) for event in events]
|
||||
|
||||
assert updates[0] is not None
|
||||
assert updates[1] is not None
|
||||
assert updates[0].additional_properties["ag_ui_custom_event"]["raw_type"] == "CUSTOM_EVENT"
|
||||
assert updates[1].additional_properties["ag_ui_custom_event"]["raw_type"] == "custom_event"
|
||||
|
||||
def test_full_conversation_flow(self) -> None:
|
||||
"""Test complete conversation flow with multiple event types."""
|
||||
converter = AGUIEventConverter()
|
||||
|
||||
@@ -107,8 +107,8 @@ async def test_post_run_successful_streaming(mock_http_client, sample_events):
|
||||
assert call_args.kwargs["headers"] == {"Accept": "text/event-stream"}
|
||||
|
||||
|
||||
async def test_post_run_with_state_and_tools(mock_http_client):
|
||||
"""Test posting run with state and tools."""
|
||||
async def test_post_run_with_state_tools_and_interrupts(mock_http_client):
|
||||
"""Test posting run with state, tools, and interrupt metadata."""
|
||||
|
||||
async def mock_aiter_lines():
|
||||
return
|
||||
@@ -127,8 +127,18 @@ async def test_post_run_with_state_and_tools(mock_http_client):
|
||||
|
||||
state = {"user_context": {"name": "Alice"}}
|
||||
tools = [{"type": "function", "function": {"name": "test_tool"}}]
|
||||
available_interrupts = [{"id": "req_1", "type": "request_info"}]
|
||||
resume = {"interrupts": [{"id": "req_1", "value": "approved"}]}
|
||||
|
||||
async for _ in service.post_run(thread_id="thread_123", run_id="run_456", messages=[], state=state, tools=tools):
|
||||
async for _ in service.post_run(
|
||||
thread_id="thread_123",
|
||||
run_id="run_456",
|
||||
messages=[],
|
||||
state=state,
|
||||
tools=tools,
|
||||
available_interrupts=available_interrupts,
|
||||
resume=resume,
|
||||
):
|
||||
pass
|
||||
|
||||
# Verify state and tools were included in request
|
||||
@@ -136,6 +146,8 @@ async def test_post_run_with_state_and_tools(mock_http_client):
|
||||
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
|
||||
|
||||
|
||||
async def test_post_run_http_error(mock_http_client):
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
"""Tests for message adapters."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content, Message
|
||||
@@ -406,6 +408,101 @@ def test_agui_non_string_content():
|
||||
assert "nested" in messages[0].contents[0].text
|
||||
|
||||
|
||||
def test_agui_multimodal_legacy_binary_to_agent_framework():
|
||||
"""Legacy text/binary multimodal content converts to text + media Content."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "See this image"},
|
||||
{"type": "binary", "mimeType": "image/png", "url": "https://example.com/image.png"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 2
|
||||
assert messages[0].contents[0].type == "text"
|
||||
assert messages[0].contents[0].text == "See this image"
|
||||
assert messages[0].contents[1].type == "uri"
|
||||
assert messages[0].contents[1].uri == "https://example.com/image.png"
|
||||
assert messages[0].contents[1].media_type == "image/png"
|
||||
|
||||
|
||||
def test_agui_multimodal_draft_source_base64_to_agent_framework():
|
||||
"""Draft-style media source payload converts into data Content."""
|
||||
payload = base64.b64encode(b"abc").decode("utf-8")
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "audio",
|
||||
"source": {"type": "base64", "data": payload, "mimeType": "audio/wav"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 1
|
||||
assert messages[0].contents[0].type == "data"
|
||||
assert messages[0].contents[0].media_type == "audio/wav"
|
||||
assert isinstance(messages[0].contents[0].uri, str)
|
||||
assert messages[0].contents[0].uri.startswith("data:audio/wav;base64,")
|
||||
|
||||
|
||||
def test_agui_multimodal_invalid_base64_logs_warning(caplog):
|
||||
"""Malformed base64 payloads should log and fall back to data URI."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "data": "abc", "mimeType": "image/png"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 1
|
||||
assert messages[0].contents[0].type in {"data", "uri"}
|
||||
assert messages[0].contents[0].uri == "data:image/png;base64,abc"
|
||||
assert any("Failed to decode AG-UI media payload as base64" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_agui_multimodal_mixed_order_preserved():
|
||||
"""Mixed text/media multimodal input keeps content ordering."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "First"},
|
||||
{"type": "image", "source": {"type": "url", "url": "https://example.com/a.png"}},
|
||||
{"type": "text", "text": "Last"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert len(messages[0].contents) == 3
|
||||
assert messages[0].contents[0].type == "text"
|
||||
assert messages[0].contents[0].text == "First"
|
||||
assert messages[0].contents[1].type == "uri"
|
||||
assert messages[0].contents[2].type == "text"
|
||||
assert messages[0].contents[2].text == "Last"
|
||||
|
||||
|
||||
def test_agui_message_without_id():
|
||||
"""Test message without ID field."""
|
||||
messages = agui_messages_to_agent_framework([{"role": "user", "content": "No ID"}])
|
||||
@@ -414,6 +511,31 @@ def test_agui_message_without_id():
|
||||
assert messages[0].message_id is None
|
||||
|
||||
|
||||
def test_agui_snapshot_format_preserves_multimodal_content():
|
||||
"""Snapshot normalization emits legacy binary parts for multimodal content."""
|
||||
normalized = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Caption"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": "https://example.com/image.png", "mime_type": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert isinstance(normalized[0]["content"], list)
|
||||
content_parts = normalized[0]["content"]
|
||||
assert content_parts[0]["type"] == "text"
|
||||
assert content_parts[1]["type"] == "binary"
|
||||
assert content_parts[1]["mimeType"] == "image/png"
|
||||
assert content_parts[1]["url"] == "https://example.com/image.png"
|
||||
|
||||
|
||||
def test_agui_with_tool_calls_to_agent_framework():
|
||||
"""Assistant message with tool_calls is converted to FunctionCallContent."""
|
||||
agui_msg = {
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_convert_approval_results_to_tool_messages() -> None:
|
||||
results ended up in user messages instead of tool messages, causing OpenAI to
|
||||
reject the request with 'tool_call_ids did not have response messages'.
|
||||
"""
|
||||
from agent_framework_ag_ui._run import _convert_approval_results_to_tool_messages
|
||||
from agent_framework_ag_ui._agent_run import _convert_approval_results_to_tool_messages
|
||||
|
||||
# Simulate what happens after _resolve_approval_responses:
|
||||
# A user message contains function_result content (the executed tool result)
|
||||
@@ -106,7 +106,7 @@ def test_convert_approval_results_preserves_other_user_content() -> None:
|
||||
the function_result content should be extracted to a tool message while the
|
||||
remaining content stays in the user message.
|
||||
"""
|
||||
from agent_framework_ag_ui._run import _convert_approval_results_to_tool_messages
|
||||
from agent_framework_ag_ui._agent_run import _convert_approval_results_to_tool_messages
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Public export coverage for AG-UI package surfaces."""
|
||||
|
||||
|
||||
def test_agent_framework_ag_ui_exports_workflow() -> None:
|
||||
"""Runtime package should export AgentFrameworkWorkflow."""
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
assert AgentFrameworkWorkflow.__name__ == "AgentFrameworkWorkflow"
|
||||
|
||||
|
||||
def test_core_ag_ui_lazy_exports_include_only_stable_api() -> None:
|
||||
"""Core facade should expose only the stable high-level AG-UI API."""
|
||||
from agent_framework import ag_ui
|
||||
|
||||
assert hasattr(ag_ui, "AgentFrameworkWorkflow")
|
||||
assert hasattr(ag_ui, "AgentFrameworkAgent")
|
||||
assert hasattr(ag_ui, "AGUIChatClient")
|
||||
assert hasattr(ag_ui, "add_agent_framework_fastapi_endpoint")
|
||||
|
||||
assert not hasattr(ag_ui, "WorkflowFactory")
|
||||
assert not hasattr(ag_ui, "AGUIRequest")
|
||||
assert not hasattr(ag_ui, "RunMetadata")
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for _run.py helper functions and FlowState."""
|
||||
"""Tests for _agent_run.py helper functions and FlowState."""
|
||||
|
||||
import pytest
|
||||
from ag_ui.core import (
|
||||
@@ -10,17 +10,25 @@ from ag_ui.core import (
|
||||
from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream
|
||||
from agent_framework.exceptions import AgentInvalidResponseException
|
||||
|
||||
from agent_framework_ag_ui._run import (
|
||||
FlowState,
|
||||
from agent_framework_ag_ui._agent_run import (
|
||||
_build_safe_metadata,
|
||||
_create_state_context_message,
|
||||
_emit_content,
|
||||
_emit_tool_result,
|
||||
_has_only_tool_calls,
|
||||
_inject_state_context,
|
||||
_normalize_response_stream,
|
||||
_resume_to_tool_messages,
|
||||
_should_suppress_intermediate_snapshot,
|
||||
)
|
||||
from agent_framework_ag_ui._run_common import (
|
||||
FlowState,
|
||||
_build_run_finished_event,
|
||||
_emit_approval_request,
|
||||
_emit_content,
|
||||
_emit_text,
|
||||
_emit_tool_call,
|
||||
_emit_tool_result,
|
||||
_extract_resume_payload,
|
||||
_has_only_tool_calls,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSafeMetadata:
|
||||
@@ -150,6 +158,7 @@ class TestFlowState:
|
||||
assert flow.tool_calls_by_id == {}
|
||||
assert flow.tool_results == []
|
||||
assert flow.tool_calls_ended == set()
|
||||
assert flow.interrupts == []
|
||||
|
||||
def test_get_tool_name(self):
|
||||
"""Tests get_tool_name method."""
|
||||
@@ -308,13 +317,11 @@ class TestInjectStateContext:
|
||||
assert "Hello" in result[2].contents[0].text
|
||||
|
||||
|
||||
# Additional tests for _run.py functions
|
||||
# Additional tests for _agent_run.py functions
|
||||
|
||||
|
||||
def test_emit_text_basic():
|
||||
"""Test _emit_text emits correct events."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("Hello world")
|
||||
|
||||
@@ -327,8 +334,6 @@ def test_emit_text_basic():
|
||||
|
||||
def test_emit_text_skip_empty():
|
||||
"""Test _emit_text skips empty text."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("")
|
||||
|
||||
@@ -339,8 +344,6 @@ def test_emit_text_skip_empty():
|
||||
|
||||
def test_emit_text_continues_existing_message():
|
||||
"""Test _emit_text continues existing message."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
flow.message_id = "existing-id"
|
||||
content = Content.from_text("more text")
|
||||
@@ -351,10 +354,21 @@ def test_emit_text_continues_existing_message():
|
||||
assert flow.message_id == "existing-id"
|
||||
|
||||
|
||||
def test_emit_text_skips_duplicate_full_message_delta():
|
||||
"""Test _emit_text skips replayed full-message chunks on an open message."""
|
||||
flow = FlowState()
|
||||
flow.message_id = "existing-id"
|
||||
flow.accumulated_text = "Case complete."
|
||||
content = Content.from_text("Case complete.")
|
||||
|
||||
events = _emit_text(content, flow)
|
||||
|
||||
assert events == []
|
||||
assert flow.accumulated_text == "Case complete."
|
||||
|
||||
|
||||
def test_emit_text_skips_when_waiting_for_approval():
|
||||
"""Test _emit_text skips when waiting for approval."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
flow.waiting_for_approval = True
|
||||
content = Content.from_text("should skip")
|
||||
@@ -366,8 +380,6 @@ def test_emit_text_skips_when_waiting_for_approval():
|
||||
|
||||
def test_emit_text_skips_when_skip_text_flag():
|
||||
"""Test _emit_text skips with skip_text flag."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("should skip")
|
||||
|
||||
@@ -378,8 +390,6 @@ def test_emit_text_skips_when_skip_text_flag():
|
||||
|
||||
def test_emit_tool_call_basic():
|
||||
"""Test _emit_tool_call emits correct events."""
|
||||
from agent_framework_ag_ui._run import _emit_tool_call
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
@@ -396,8 +406,6 @@ def test_emit_tool_call_basic():
|
||||
|
||||
def test_emit_tool_call_generates_id():
|
||||
"""Test _emit_tool_call generates ID when not provided."""
|
||||
from agent_framework_ag_ui._run import _emit_tool_call
|
||||
|
||||
flow = FlowState()
|
||||
# Create content without call_id
|
||||
content = Content(type="function_call", name="test_tool", arguments="{}")
|
||||
@@ -452,9 +460,100 @@ def test_emit_tool_result_no_open_message():
|
||||
assert len(text_end_events) == 0
|
||||
|
||||
|
||||
def test_emit_tool_result_serializes_non_string_result():
|
||||
"""Non-string tool results should be serialized before emitting TOOL_CALL_RESULT."""
|
||||
flow = FlowState()
|
||||
content = Content.from_function_result(call_id="call_789", result={"ok": True, "items": [1, 2]})
|
||||
|
||||
events = _emit_tool_result(content, flow, predictive_handler=None)
|
||||
result_event = next(event for event in events if getattr(event, "type", None) == "TOOL_CALL_RESULT")
|
||||
|
||||
assert isinstance(result_event.content, str)
|
||||
assert '"ok": true' in result_event.content
|
||||
assert flow.tool_results[0]["content"] == result_event.content
|
||||
|
||||
|
||||
def test_emit_content_usage_emits_custom_usage_event():
|
||||
"""Usage content should be emitted as a custom usage event."""
|
||||
flow = FlowState()
|
||||
content = Content.from_usage({"input_token_count": 3, "output_token_count": 2, "total_token_count": 5})
|
||||
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "CUSTOM"
|
||||
assert events[0].name == "usage"
|
||||
assert events[0].value["total_token_count"] == 5
|
||||
|
||||
|
||||
def test_emit_approval_request_populates_interrupt_metadata():
|
||||
"""Approval requests should populate FlowState interrupts for RUN_FINISHED metadata."""
|
||||
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)
|
||||
|
||||
_emit_approval_request(approval_content, flow)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_resume_to_tool_messages_from_interrupts_payload():
|
||||
"""Resume payload interrupt responses map to tool messages."""
|
||||
resume = {
|
||||
"interrupts": [
|
||||
{"id": "req_1", "value": {"accepted": True, "steps": []}},
|
||||
{"id": "req_2", "value": "plain value"},
|
||||
]
|
||||
}
|
||||
|
||||
messages = _resume_to_tool_messages(resume)
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "tool"
|
||||
assert messages[0]["toolCallId"] == "req_1"
|
||||
assert '"accepted": true' in messages[0]["content"]
|
||||
assert messages[1]["content"] == "plain value"
|
||||
|
||||
|
||||
def test_extract_resume_payload_prefers_top_level_resume():
|
||||
"""Top-level resume should take precedence over forwarded props."""
|
||||
payload = {
|
||||
"resume": {"interrupts": [{"id": "req_1", "value": "approved"}]},
|
||||
"forwarded_props": {"command": {"resume": "ignored"}},
|
||||
}
|
||||
|
||||
result = _extract_resume_payload(payload)
|
||||
assert result == {"interrupts": [{"id": "req_1", "value": "approved"}]}
|
||||
|
||||
|
||||
def test_extract_resume_payload_reads_forwarded_command_resume():
|
||||
"""Forwarded command.resume should be treated as a resume payload."""
|
||||
payload = {
|
||||
"forwarded_props": {
|
||||
"command": {"resume": '{"airline":"KLM","departure":"Amsterdam (AMS)","arrival":"San Francisco (SFO)"}'}
|
||||
}
|
||||
}
|
||||
|
||||
result = _extract_resume_payload(payload)
|
||||
assert isinstance(result, str)
|
||||
assert "KLM" in result
|
||||
|
||||
|
||||
def test_build_run_finished_event_with_interrupt():
|
||||
"""RUN_FINISHED helper should preserve interrupt payloads."""
|
||||
event = _build_run_finished_event("run-1", "thread-1", interrupts=[{"id": "req_1", "value": {"x": 1}}])
|
||||
dumped = event.model_dump()
|
||||
|
||||
assert dumped["run_id"] == "run-1"
|
||||
assert dumped["thread_id"] == "thread-1"
|
||||
assert dumped["interrupt"] == [{"id": "req_1", "value": {"x": 1}}]
|
||||
|
||||
|
||||
def test_extract_approved_state_updates_no_handler():
|
||||
"""Test _extract_approved_state_updates returns empty with no handler."""
|
||||
from agent_framework_ag_ui._run import _extract_approved_state_updates
|
||||
from agent_framework_ag_ui._agent_run import _extract_approved_state_updates
|
||||
|
||||
messages = [Message(role="user", contents=[Content.from_text("Hello")])]
|
||||
result = _extract_approved_state_updates(messages, None)
|
||||
@@ -463,8 +562,8 @@ def test_extract_approved_state_updates_no_handler():
|
||||
|
||||
def test_extract_approved_state_updates_no_approval():
|
||||
"""Test _extract_approved_state_updates returns empty when no approval content."""
|
||||
from agent_framework_ag_ui._agent_run import _extract_approved_state_updates
|
||||
from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler
|
||||
from agent_framework_ag_ui._run import _extract_approved_state_updates
|
||||
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "content"}})
|
||||
messages = [Message(role="user", contents=[Content.from_text("Hello")])]
|
||||
@@ -481,7 +580,7 @@ class TestBuildMessagesSnapshot:
|
||||
This is a regression test for issue #3619 where tool calls and content
|
||||
were incorrectly merged into a single assistant message.
|
||||
"""
|
||||
from agent_framework_ag_ui._run import FlowState, _build_messages_snapshot
|
||||
from agent_framework_ag_ui._agent_run import FlowState, _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
flow.message_id = "msg-123"
|
||||
@@ -518,7 +617,7 @@ class TestBuildMessagesSnapshot:
|
||||
|
||||
def test_only_tool_calls_no_text(self):
|
||||
"""Test snapshot with only tool calls and no accumulated text."""
|
||||
from agent_framework_ag_ui._run import FlowState, _build_messages_snapshot
|
||||
from agent_framework_ag_ui._agent_run import FlowState, _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
flow.message_id = "msg-123"
|
||||
@@ -538,7 +637,7 @@ class TestBuildMessagesSnapshot:
|
||||
|
||||
def test_only_text_no_tool_calls(self):
|
||||
"""Test snapshot with only text and no tool calls."""
|
||||
from agent_framework_ag_ui._run import FlowState, _build_messages_snapshot
|
||||
from agent_framework_ag_ui._agent_run import FlowState, _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
flow.message_id = "msg-123"
|
||||
@@ -558,7 +657,7 @@ class TestBuildMessagesSnapshot:
|
||||
|
||||
def test_preserves_snapshot_messages(self):
|
||||
"""Test that existing snapshot messages are preserved."""
|
||||
from agent_framework_ag_ui._run import FlowState, _build_messages_snapshot
|
||||
from agent_framework_ag_ui._agent_run import FlowState, _build_messages_snapshot
|
||||
|
||||
flow = FlowState()
|
||||
flow.pending_tool_calls = []
|
||||
|
||||
@@ -32,7 +32,7 @@ async def test_service_thread_id_when_there_are_updates(stub_agent):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
assert isinstance(events[0], RunStartedEvent)
|
||||
@@ -54,7 +54,7 @@ async def test_service_thread_id_when_no_user_message(stub_agent):
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 2
|
||||
@@ -74,7 +74,7 @@ async def test_service_thread_id_when_user_supplied_thread_id(stub_agent):
|
||||
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}], "threadId": "conv_12345"}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
assert isinstance(events[0], RunStartedEvent)
|
||||
|
||||
@@ -52,7 +52,7 @@ async def test_structured_output_with_recipe(streaming_chat_client_stub, stream_
|
||||
input_data = {"messages": [{"role": "user", "content": "Make pasta"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent with recipe
|
||||
@@ -94,7 +94,7 @@ async def test_structured_output_with_steps(streaming_chat_client_stub, stream_f
|
||||
input_data = {"messages": [{"role": "user", "content": "Do steps"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent with steps
|
||||
@@ -129,7 +129,7 @@ async def test_structured_output_with_no_schema_match(streaming_chat_client_stub
|
||||
input_data = {"messages": [{"role": "user", "content": "Generate data"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent but with no state updates since no schema fields match
|
||||
@@ -164,7 +164,7 @@ async def test_structured_output_without_schema(streaming_chat_client_stub, stre
|
||||
input_data = {"messages": [{"role": "user", "content": "Generate data"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent with both data and info fields
|
||||
@@ -194,7 +194,7 @@ async def test_no_structured_output_when_no_response_format(streaming_chat_clien
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit text content normally
|
||||
@@ -224,7 +224,7 @@ async def test_structured_output_with_message_field(streaming_chat_client_stub,
|
||||
input_data = {"messages": [{"role": "user", "content": "Make salad"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should emit the message as text
|
||||
@@ -256,7 +256,7 @@ async def test_empty_updates_no_structured_processing(streaming_chat_client_stub
|
||||
input_data = {"messages": [{"role": "user", "content": "Test"}]}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should only have start and end events
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the subgraphs example agent used by Dojo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
|
||||
|
||||
|
||||
async def _run(agent: Any, payload: dict[str, Any]) -> list[Any]:
|
||||
return [event async for event in agent.run(payload)]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-subgraphs-initial",
|
||||
"run_id": "run-initial",
|
||||
"messages": [{"role": "user", "content": "Help me plan a trip to San Francisco"}],
|
||||
},
|
||||
)
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert event_types[0] == "RUN_STARTED"
|
||||
assert "STATE_SNAPSHOT" in event_types
|
||||
assert "STEP_STARTED" in event_types
|
||||
assert "STEP_FINISHED" in event_types
|
||||
assert "TEXT_MESSAGE_CONTENT" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
started_steps = [event.step_name for event in events if event.type == "STEP_STARTED"]
|
||||
finished_steps = [event.step_name for event in events if event.type == "STEP_FINISHED"]
|
||||
assert "supervisor_agent" in started_steps
|
||||
assert "flights_agent" in started_steps
|
||||
assert "supervisor_agent" in finished_steps
|
||||
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)
|
||||
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"
|
||||
custom_event_names = [event.name for event in events if event.type == "CUSTOM"]
|
||||
assert "WorkflowInterruptEvent" in custom_event_names
|
||||
|
||||
|
||||
async def test_subgraphs_example_resume_flow_reaches_completion() -> None:
|
||||
"""Flight + hotel resume payloads should complete the itinerary state."""
|
||||
agent = subgraphs_agent()
|
||||
thread_id = "thread-subgraphs-complete"
|
||||
|
||||
first_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-1",
|
||||
"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]
|
||||
|
||||
second_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-2",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": first_interrupt["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"airline": "United",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$720",
|
||||
"duration": "12h 15m",
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
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"
|
||||
|
||||
third_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-3",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": second_interrupt[0]["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"name": "The Ritz-Carlton",
|
||||
"location": "Nob Hill",
|
||||
"price_per_night": "$550/night",
|
||||
"rating": "4.8 stars",
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
third_finished = [event for event in third_events if event.type == "RUN_FINISHED"][0].model_dump()
|
||||
assert "interrupt" not in third_finished
|
||||
|
||||
snapshots = [event.snapshot for event in third_events if event.type == "STATE_SNAPSHOT"]
|
||||
assert snapshots
|
||||
final_snapshot = snapshots[-1]
|
||||
assert final_snapshot["planning_step"] == "complete"
|
||||
assert final_snapshot["active_agent"] == "supervisor"
|
||||
assert final_snapshot["itinerary"]["flight"]["airline"] == "United"
|
||||
assert final_snapshot["itinerary"]["hotel"]["name"] == "The Ritz-Carlton"
|
||||
assert len(final_snapshot["experiences"]) == 4
|
||||
|
||||
|
||||
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 = subgraphs_agent()
|
||||
thread_id = "thread-subgraphs-text"
|
||||
|
||||
first_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-a",
|
||||
"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"
|
||||
|
||||
second_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-b",
|
||||
"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]
|
||||
assert "TEXT_MESSAGE_CONTENT" not in [event.type for event in second_events]
|
||||
|
||||
third_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-c",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": second_finished["interrupt"][0]["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"airline": "United",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$720",
|
||||
"duration": "12h 15m",
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
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_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."""
|
||||
agent = subgraphs_agent()
|
||||
thread_id = "thread-subgraphs-forwarded-resume"
|
||||
|
||||
first_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-forwarded-1",
|
||||
"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]
|
||||
|
||||
second_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-forwarded-2",
|
||||
"messages": [],
|
||||
"forwarded_props": {
|
||||
"command": {
|
||||
"resume": json.dumps(
|
||||
{
|
||||
"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"
|
||||
assert second_interrupt[0]["id"] != first_interrupt["id"]
|
||||
@@ -183,6 +183,21 @@ class TestAGUIRequest:
|
||||
assert request.forwarded_props == {"custom_key": "custom_value"}
|
||||
assert request.parent_run_id == "parent-run-789"
|
||||
|
||||
def test_agui_request_camel_case_aliases(self) -> None:
|
||||
"""Test AGUIRequest accepts camelCase aliases from AG-UI HTTP clients."""
|
||||
request = AGUIRequest(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
runId="run-camel-1",
|
||||
threadId="thread-camel-1",
|
||||
forwardedProps={"k": "v"},
|
||||
parentRunId="parent-camel-1",
|
||||
)
|
||||
|
||||
assert request.run_id == "run-camel-1"
|
||||
assert request.thread_id == "thread-camel-1"
|
||||
assert request.forwarded_props == {"k": "v"}
|
||||
assert request.parent_run_id == "parent-camel-1"
|
||||
|
||||
def test_agui_request_model_dump_excludes_none(self) -> None:
|
||||
"""Test that model_dump(exclude_none=True) excludes None fields."""
|
||||
request = AGUIRequest(
|
||||
@@ -223,3 +238,15 @@ class TestAGUIRequest:
|
||||
assert dumped["context"] == [{"type": "snippet", "content": "code here"}]
|
||||
assert dumped["forwarded_props"] == {"auth_token": "secret", "user_id": "user-1"}
|
||||
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."""
|
||||
request = AGUIRequest(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
availableInterrupts=[{"id": "req_1", "value": {"choice": "A"}}],
|
||||
)
|
||||
|
||||
assert request.available_interrupts == [{"id": "req_1", "value": {"choice": "A"}}]
|
||||
dumped = request.model_dump(exclude_none=True)
|
||||
assert dumped["available_interrupts"] == [{"id": "req_1", "value": {"choice": "A"}}]
|
||||
assert "availableInterrupts" not in dumped
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for AgentFrameworkWorkflow wrapper behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from agent_framework import Workflow, WorkflowBuilder, WorkflowContext, executor
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkWorkflow, payload: dict[str, Any]) -> list[Any]:
|
||||
return [event async for event in agent.run(payload)]
|
||||
|
||||
|
||||
async def test_workflow_wrapper_rejects_workflow_and_factory_at_once() -> None:
|
||||
"""Workflow wrapper should reject ambiguous workflow source configuration."""
|
||||
|
||||
@executor(id="start")
|
||||
async def start(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.yield_output("ok")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).build()
|
||||
with pytest.raises(ValueError, match="workflow_factory"):
|
||||
AgentFrameworkWorkflow(workflow=workflow, workflow_factory=lambda _thread_id: workflow)
|
||||
|
||||
|
||||
async def test_workflow_wrapper_factory_is_thread_scoped() -> None:
|
||||
"""Thread-scoped workflow factories should isolate workflow instances by thread id."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info({"message": "Choose an option", "options": ["a", "b"]}, dict, request_id="choice")
|
||||
|
||||
factory_calls: dict[str, int] = {}
|
||||
|
||||
def workflow_factory(thread_id: str) -> Workflow:
|
||||
factory_calls[thread_id] = factory_calls.get(thread_id, 0) + 1
|
||||
return WorkflowBuilder(start_executor=requester).build()
|
||||
|
||||
agent = AgentFrameworkWorkflow(workflow_factory=workflow_factory)
|
||||
|
||||
first_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-a",
|
||||
"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)
|
||||
assert first_interrupt[0]["id"] == "choice"
|
||||
assert factory_calls["thread-a"] == 1
|
||||
|
||||
second_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-a",
|
||||
"messages": [],
|
||||
"resume": {"interrupts": [{"id": "choice", "value": {"selection": "a"}}]},
|
||||
},
|
||||
)
|
||||
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
|
||||
assert factory_calls["thread-a"] == 1
|
||||
|
||||
third_events = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-b",
|
||||
"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)
|
||||
assert third_interrupt[0]["id"] == "choice"
|
||||
assert factory_calls["thread-b"] == 1
|
||||
|
||||
agent.clear_thread_workflow("thread-a")
|
||||
await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-a",
|
||||
"messages": [{"role": "user", "content": "restart"}],
|
||||
},
|
||||
)
|
||||
assert factory_calls["thread-a"] == 2
|
||||
|
||||
|
||||
async def test_workflow_wrapper_without_workflow_raises_not_implemented() -> None:
|
||||
"""Without workflow/workflow_factory, run should raise NotImplementedError."""
|
||||
agent = AgentFrameworkWorkflow()
|
||||
|
||||
with pytest.raises(NotImplementedError, match="No workflow is attached"):
|
||||
_ = [event async for event in agent.run({"messages": [{"role": "user", "content": "start"}]})]
|
||||
|
||||
|
||||
async def test_workflow_wrapper_factory_return_type_is_validated() -> None:
|
||||
"""Factory outputs must be Workflow instances."""
|
||||
agent = AgentFrameworkWorkflow(workflow_factory=lambda _thread_id: cast(Any, object()))
|
||||
|
||||
with pytest.raises(TypeError, match="workflow_factory must return a Workflow instance"):
|
||||
_ = [event async for event in agent.run({"thread_id": "thread-a", "messages": []})]
|
||||
@@ -0,0 +1,679 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for native workflow AG-UI runner."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from ag_ui.core import EventType, StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
Content,
|
||||
Executor,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework_ag_ui._workflow_run import (
|
||||
_coerce_message,
|
||||
_coerce_response_for_request,
|
||||
run_workflow_stream,
|
||||
)
|
||||
|
||||
|
||||
class ProgressEvent(WorkflowEvent):
|
||||
"""Custom workflow event used to validate CUSTOM mapping."""
|
||||
|
||||
def __init__(self, progress: int) -> None:
|
||||
super().__init__("custom_progress", data={"progress": progress})
|
||||
|
||||
|
||||
async def test_workflow_run_maps_custom_and_text_events():
|
||||
"""Custom workflow events and yielded text are mapped to AG-UI events."""
|
||||
|
||||
@executor(id="start")
|
||||
async def start(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.add_event(ProgressEvent(10))
|
||||
await ctx.yield_output("Hello workflow")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).build()
|
||||
input_data = {"messages": [{"role": "user", "content": "go"}]}
|
||||
|
||||
events = [event async for event in run_workflow_stream(input_data, workflow)]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "CUSTOM" in event_types
|
||||
assert "TEXT_MESSAGE_CONTENT" in event_types
|
||||
assert "STEP_STARTED" in event_types
|
||||
assert "STEP_FINISHED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
custom_events = [event for event in events if event.type == "CUSTOM" and event.name == "custom_progress"]
|
||||
assert len(custom_events) == 1
|
||||
assert custom_events[0].value == {"progress": 10}
|
||||
|
||||
|
||||
async def test_workflow_run_request_info_emits_interrupt_and_resume_works():
|
||||
"""request_info should emit interrupt metadata and resume should continue run."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info("Need approval", str)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
|
||||
first_run_events = [
|
||||
event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)
|
||||
]
|
||||
|
||||
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)
|
||||
assert len(interrupt_payload) == 1
|
||||
|
||||
request_id = str(interrupt_payload[0]["id"])
|
||||
assert request_id
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [], "resume": {"interrupts": [{"id": request_id, "value": "approved"}]}},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
resumed_types = [event.type for event in resumed_events]
|
||||
assert "RUN_STARTED" in resumed_types
|
||||
assert "RUN_FINISHED" in resumed_types
|
||||
assert "RUN_ERROR" not in resumed_types
|
||||
|
||||
|
||||
async def test_workflow_run_request_info_closes_open_text_message() -> None:
|
||||
"""Text output should end before request_info interrupt events begin."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.yield_output("Please confirm this action.")
|
||||
await ctx.request_info("Need approval", str, request_id="approval-1")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
content_index = next(i for i, event in enumerate(events) if event.type == "TEXT_MESSAGE_CONTENT")
|
||||
end_index = next(i for i, event in enumerate(events) if event.type == "TEXT_MESSAGE_END")
|
||||
request_start_index = next(
|
||||
i
|
||||
for i, event in enumerate(events)
|
||||
if event.type == "TOOL_CALL_START" and getattr(event, "tool_call_id", None) == "approval-1"
|
||||
)
|
||||
|
||||
assert content_index < end_index < request_start_index
|
||||
|
||||
|
||||
async def test_workflow_run_request_info_interrupt_uses_raw_dict_value():
|
||||
"""Dict request payloads should be surfaced directly in RUN_FINISHED.interrupt.value."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(
|
||||
{
|
||||
"message": "Choose a flight",
|
||||
"options": [{"airline": "KLM"}],
|
||||
"recommendation": {"airline": "KLM"},
|
||||
"agent": "flights",
|
||||
},
|
||||
dict,
|
||||
request_id="flights-choice",
|
||||
)
|
||||
|
||||
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)
|
||||
assert interrupt_payload[0]["id"] == "flights-choice"
|
||||
assert interrupt_payload[0]["value"]["agent"] == "flights"
|
||||
assert interrupt_payload[0]["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."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info({"options": [{"airline": "KLM"}]}, dict, request_id="flights-choice")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
_ = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [],
|
||||
"forwarded_props": {
|
||||
"command": {"resume": json.dumps({"airline": "KLM", "departure": "AMS", "arrival": "SFO"})}
|
||||
},
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info({"options": [{"name": "Hotel Zoe"}]}, dict, request_id="hotel-choice")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
_ = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [{"role": "user", "content": json.dumps({"name": "Hotel Zoe"})}],
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def test_workflow_run_resume_content_response_from_json_payload() -> None:
|
||||
"""JSON resume payloads should coerce into Content responses for approval requests."""
|
||||
|
||||
class ApprovalExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="approval_executor")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
function_call = Content.from_function_call(
|
||||
call_id="refund-call",
|
||||
name="submit_refund",
|
||||
arguments={"order_id": "12345", "amount": "$89.99"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
|
||||
await ctx.request_info(approval_request, Content, request_id="approval-1")
|
||||
|
||||
@response_handler
|
||||
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
|
||||
del original_request
|
||||
status = "approved" if bool(response.approved) else "rejected"
|
||||
await ctx.yield_output(f"Refund tool call {status}.")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
|
||||
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"])
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [],
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": "approval-1",
|
||||
"value": {
|
||||
"type": "function_approval_response",
|
||||
"approved": True,
|
||||
"id": interrupt_value.get("id", "approval-1"),
|
||||
"function_call": interrupt_value.get("function_call"),
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
resumed_types = [event.type for event in resumed_events]
|
||||
assert "RUN_ERROR" not in resumed_types
|
||||
assert "TEXT_MESSAGE_CONTENT" in resumed_types
|
||||
resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump()
|
||||
assert "interrupt" not in resumed_finished
|
||||
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)
|
||||
|
||||
|
||||
async def test_workflow_run_resume_message_list_from_json_payload() -> None:
|
||||
"""Resume payloads should coerce AG-UI message dictionaries into list[Message] responses."""
|
||||
|
||||
class MessageRequestExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="message_request_executor")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info({"prompt": "Need user follow-up"}, list[Message], request_id="handoff-user-input")
|
||||
|
||||
@response_handler
|
||||
async def handle_user_input(
|
||||
self, original_request: dict, response: list[Message], ctx: WorkflowContext
|
||||
) -> None:
|
||||
del original_request
|
||||
user_text = response[0].text if response else ""
|
||||
await ctx.yield_output(f"Captured response: {user_text}")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=MessageRequestExecutor()).build()
|
||||
_ = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "start"}]}, workflow)]
|
||||
|
||||
resumed_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [],
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": "handoff-user-input",
|
||||
"value": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [{"type": "text", "text": "Please ship a replacement instead."}],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
resumed_types = [event.type for event in resumed_events]
|
||||
assert "RUN_ERROR" not in resumed_types
|
||||
assert "TEXT_MESSAGE_CONTENT" in resumed_types
|
||||
resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump()
|
||||
assert "interrupt" not in resumed_finished
|
||||
text_deltas = [event.delta for event in resumed_events if event.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert any("replacement" in delta for delta in text_deltas)
|
||||
|
||||
|
||||
async def test_workflow_run_non_chat_output_maps_to_custom_output_event():
|
||||
"""Non-chat workflow outputs are emitted as CUSTOM workflow_output events."""
|
||||
|
||||
@executor(id="structured")
|
||||
async def structured(message: Any, ctx: WorkflowContext[Never, dict[str, int]]) -> None:
|
||||
await ctx.yield_output({"count": 3})
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=structured).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
output_custom = [event for event in events if event.type == "CUSTOM" and event.name == "workflow_output"]
|
||||
assert len(output_custom) == 1
|
||||
assert output_custom[0].value == {"count": 3}
|
||||
|
||||
|
||||
async def test_workflow_run_passthroughs_ag_ui_base_events():
|
||||
"""Workflow outputs that are AG-UI BaseEvent instances should be emitted directly."""
|
||||
|
||||
@executor(id="stateful")
|
||||
async def stateful(message: Any, ctx: WorkflowContext[Never, StateSnapshotEvent]) -> None:
|
||||
await ctx.yield_output(StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"active_agent": "flights"}))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=stateful).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
snapshots = [event for event in events if event.type == "STATE_SNAPSHOT"]
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].snapshot["active_agent"] == "flights"
|
||||
|
||||
|
||||
async def test_workflow_run_plain_text_follow_up_does_not_infer_interrupt_response():
|
||||
"""User follow-up text should not be coerced into request_info responses for workflows."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info(
|
||||
{
|
||||
"message": "Choose a flight",
|
||||
"options": [{"airline": "KLM"}, {"airline": "United"}],
|
||||
"agent": "flights",
|
||||
},
|
||||
dict,
|
||||
request_id="flights-choice",
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
_ = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
follow_up_events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "flights-choice",
|
||||
"type": "function",
|
||||
"function": {"name": "request_info", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "I prefer KLM please"},
|
||||
]
|
||||
},
|
||||
workflow,
|
||||
)
|
||||
]
|
||||
|
||||
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"
|
||||
|
||||
|
||||
async def test_workflow_run_empty_turn_with_pending_request_preserves_interrupts():
|
||||
"""An empty turn should still return pending workflow interrupts without errors."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
del message
|
||||
await ctx.request_info({"prompt": "choose"}, dict, request_id="pick-one")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
_ = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
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"
|
||||
|
||||
|
||||
async def test_workflow_run_agent_response_output_uses_latest_assistant_message_only() -> None:
|
||||
"""Conversation payload outputs should not flatten full history into one assistant message."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Never, AgentResponse]) -> None:
|
||||
del message
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
Message(role="user", contents=[Content.from_text("My order arrived damaged")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text("Order Agent: Got it. I submitted the replacement request.")],
|
||||
),
|
||||
]
|
||||
)
|
||||
await ctx.yield_output(response)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert text_deltas == ["Order Agent: Got it. I submitted the replacement request."]
|
||||
|
||||
|
||||
async def test_workflow_run_skips_duplicate_text_from_conversation_snapshot() -> None:
|
||||
"""Do not emit duplicate assistant text when a snapshot repeats the latest output."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
|
||||
del message
|
||||
duplicate_text = "Order Agent: Got it. I submitted the replacement request."
|
||||
await ctx.yield_output(duplicate_text)
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(role="user", contents=[Content.from_text("standard")]),
|
||||
Message(role="assistant", contents=[Content.from_text(duplicate_text)]),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert text_deltas == ["Order Agent: Got it. I submitted the replacement request."]
|
||||
|
||||
|
||||
async def test_workflow_run_skips_consecutive_duplicate_text_outputs() -> None:
|
||||
"""Do not emit duplicate assistant text when consecutive outputs are identical."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
|
||||
del message
|
||||
duplicate_text = "Order Agent: Replacement processed. Case complete."
|
||||
await ctx.yield_output(duplicate_text)
|
||||
await ctx.yield_output(duplicate_text)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert text_deltas == ["Order Agent: Replacement processed. Case complete."]
|
||||
|
||||
|
||||
async def test_workflow_run_skips_final_snapshot_when_streamed_chunks_already_match() -> None:
|
||||
"""Do not append full snapshot text when prior chunk outputs already formed the same message."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
|
||||
del message
|
||||
full_text = (
|
||||
"Your replacement request for order 28939393 has been submitted with expedited shipping, "
|
||||
"as you requested.\n\nCase complete."
|
||||
)
|
||||
await ctx.yield_output(
|
||||
"Your replacement request for order 28939393 has been submitted with expedited shipping, "
|
||||
)
|
||||
await ctx.yield_output("as you requested.\n\nCase complete.")
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(role="user", contents=[Content.from_text("My order is 28939393.")]),
|
||||
Message(role="assistant", contents=[Content.from_text(full_text)]),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert text_deltas == [
|
||||
"Your replacement request for order 28939393 has been submitted with expedited shipping, ",
|
||||
"as you requested.\n\nCase complete.",
|
||||
]
|
||||
|
||||
|
||||
async def test_workflow_run_usage_content_emits_custom_usage_event() -> None:
|
||||
"""Usage output from workflows should be surfaced as a custom usage event."""
|
||||
|
||||
@executor(id="usage")
|
||||
async def usage(message: Any, ctx: WorkflowContext[Never, Content]) -> None:
|
||||
del message
|
||||
await ctx.yield_output(
|
||||
Content.from_usage(
|
||||
{
|
||||
"input_token_count": 12,
|
||||
"output_token_count": 6,
|
||||
"total_token_count": 18,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=usage).build()
|
||||
events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)]
|
||||
|
||||
usage_events = [event for event in events if event.type == "CUSTOM" and event.name == "usage"]
|
||||
assert len(usage_events) == 1
|
||||
assert usage_events[0].value["input_token_count"] == 12
|
||||
assert usage_events[0].value["output_token_count"] == 6
|
||||
assert usage_events[0].value["total_token_count"] == 18
|
||||
|
||||
|
||||
async def test_workflow_run_accepts_multimodal_input_messages() -> None:
|
||||
"""Workflow runner should normalize multimodal input into workflow Message content."""
|
||||
|
||||
class CapturingWorkflow:
|
||||
def __init__(self) -> None:
|
||||
self.captured_message: list[Message] | None = None
|
||||
|
||||
def run(self, **kwargs: Any):
|
||||
self.captured_message = cast(list[Message] | None, kwargs.get("message"))
|
||||
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
|
||||
return _stream()
|
||||
|
||||
workflow = CapturingWorkflow()
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Please analyze this image"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": "https://example.com/diagram.png",
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
cast(Any, workflow),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
assert "RUN_ERROR" not in event_types
|
||||
|
||||
assert workflow.captured_message is not None
|
||||
assert len(workflow.captured_message) == 1
|
||||
user_message = workflow.captured_message[0]
|
||||
assert user_message.role == "user"
|
||||
assert len(user_message.contents) == 2
|
||||
assert user_message.contents[0].type == "text"
|
||||
assert user_message.contents[0].text == "Please analyze this image"
|
||||
assert user_message.contents[1].type == "uri"
|
||||
assert user_message.contents[1].uri == "https://example.com/diagram.png"
|
||||
|
||||
|
||||
def test_coerce_message_accepts_string_payload() -> None:
|
||||
"""String values should coerce into a user Message with one text content."""
|
||||
message = _coerce_message("Please continue")
|
||||
assert message is not None
|
||||
assert message.role == "user"
|
||||
assert len(message.contents) == 1
|
||||
assert message.contents[0].type == "text"
|
||||
assert message.contents[0].text == "Please continue"
|
||||
|
||||
|
||||
def test_coerce_message_accepts_content_key_variant() -> None:
|
||||
"""The 'content' key variant should map into Message.contents."""
|
||||
message = _coerce_message({"role": "assistant", "content": {"type": "text", "content": "Done"}})
|
||||
assert message is not None
|
||||
assert message.role == "assistant"
|
||||
assert len(message.contents) == 1
|
||||
assert message.contents[0].type == "text"
|
||||
assert message.contents[0].text == "Done"
|
||||
|
||||
|
||||
def test_coerce_response_for_request_bool_int_float_and_mismatch() -> None:
|
||||
"""Scalar coercion should enforce bool/int/float rules and return None on mismatches."""
|
||||
bool_request = SimpleNamespace(response_type=bool)
|
||||
assert _coerce_response_for_request(bool_request, True) is True
|
||||
assert _coerce_response_for_request(bool_request, "true") is True
|
||||
assert _coerce_response_for_request(bool_request, 1) is None
|
||||
|
||||
int_request = SimpleNamespace(response_type=int)
|
||||
assert _coerce_response_for_request(int_request, 7) == 7
|
||||
assert _coerce_response_for_request(int_request, "7") == 7
|
||||
assert _coerce_response_for_request(int_request, True) is None
|
||||
|
||||
float_request = SimpleNamespace(response_type=float)
|
||||
assert _coerce_response_for_request(float_request, 2) == 2
|
||||
assert _coerce_response_for_request(float_request, "2.5") == 2.5
|
||||
assert _coerce_response_for_request(float_request, True) is None
|
||||
|
||||
dict_request = SimpleNamespace(response_type=dict)
|
||||
assert _coerce_response_for_request(dict_request, "[1,2,3]") is None
|
||||
|
||||
|
||||
async def test_workflow_run_emits_run_error_when_stream_raises() -> None:
|
||||
"""Unexpected stream exceptions should be converted into RUN_ERROR events."""
|
||||
|
||||
class FailingWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
del kwargs
|
||||
|
||||
async def _stream():
|
||||
raise RuntimeError("workflow stream exploded")
|
||||
yield # pragma: no cover
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]},
|
||||
cast(Any, FailingWorkflow()),
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert event_types[0] == "RUN_STARTED"
|
||||
assert "RUN_ERROR" in event_types
|
||||
run_error = next(event for event in events if event.type == "RUN_ERROR")
|
||||
assert "workflow stream exploded" in run_error.message
|
||||
@@ -14,6 +14,7 @@ from collections.abc import (
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from contextlib import suppress
|
||||
from functools import partial, wraps
|
||||
from time import perf_counter, time_ns
|
||||
from typing import (
|
||||
@@ -288,15 +289,18 @@ class FunctionTool(SerializationMixin):
|
||||
self.func = func
|
||||
self._instance = None # Store the instance for bound methods
|
||||
|
||||
# Initialize schema cache (will be lazily populated)
|
||||
self._input_schema_cached: dict[str, Any] | None = None
|
||||
|
||||
# Track if schema was supplied as JSON dict (for optimization)
|
||||
if isinstance(input_model, Mapping):
|
||||
self._schema_supplied = True
|
||||
self._input_schema: dict[str, Any] = dict(input_model)
|
||||
self._input_schema_cached = dict(input_model)
|
||||
self.input_model: type[BaseModel] | None = None
|
||||
else:
|
||||
self._schema_supplied = False
|
||||
self.input_model = self._resolve_input_model(input_model)
|
||||
self._input_schema = self.input_model.model_json_schema()
|
||||
# Defer schema generation to avoid issues with forward references
|
||||
self._cached_parameters: dict[str, Any] | None = None
|
||||
self.approval_mode = approval_mode or "never_require"
|
||||
if max_invocations is not None and max_invocations < 1:
|
||||
@@ -546,6 +550,19 @@ class FunctionTool(SerializationMixin):
|
||||
self._invocation_duration_histogram.record(duration, attributes=attributes)
|
||||
logger.info("Function duration: %fs", duration)
|
||||
|
||||
@property
|
||||
def _input_schema(self) -> dict[str, Any]:
|
||||
"""Get the input schema, generating it lazily if needed."""
|
||||
if self._input_schema_cached is None:
|
||||
if self.input_model is not None:
|
||||
# Try to rebuild the model in case it has forward references
|
||||
with suppress(Exception):
|
||||
self.input_model.model_rebuild(force=True, raise_errors=False)
|
||||
self._input_schema_cached = self.input_model.model_json_schema()
|
||||
else:
|
||||
self._input_schema_cached = {}
|
||||
return self._input_schema_cached
|
||||
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""Create the JSON schema of the parameters.
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ IMPORT_PATH = "agent_framework_ag_ui"
|
||||
PACKAGE_NAME = "agent-framework-ag-ui"
|
||||
_IMPORTS = [
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"AGUIChatClient",
|
||||
"AGUIEventConverter",
|
||||
"AGUIHttpService",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from agent_framework_ag_ui import (
|
||||
AgentFrameworkAgent,
|
||||
AgentFrameworkWorkflow,
|
||||
AGUIChatClient,
|
||||
AGUIEventConverter,
|
||||
AGUIHttpService,
|
||||
__version__,
|
||||
add_agent_framework_fastapi_endpoint,
|
||||
)
|
||||
|
||||
@@ -13,5 +15,7 @@ __all__ = [
|
||||
"AGUIEventConverter",
|
||||
"AGUIHttpService",
|
||||
"AgentFrameworkAgent",
|
||||
"AgentFrameworkWorkflow",
|
||||
"__version__",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
]
|
||||
|
||||
@@ -362,9 +362,7 @@ async def test_run_request_with_full_history_clears_service_session_id() -> None
|
||||
"""Replaying a full conversation (including function calls) via AgentExecutorRequest must
|
||||
clear service_session_id so the API does not receive both previous_response_id and the
|
||||
same function-call items in input — which would cause a 'Duplicate item' API error."""
|
||||
tool_agent = _ToolHistoryAgent(
|
||||
id="tool_agent", name="ToolAgent", summary_text="Done."
|
||||
)
|
||||
tool_agent = _ToolHistoryAgent(id="tool_agent", name="ToolAgent", summary_text="Done.")
|
||||
tool_exec = AgentExecutor(tool_agent, id="tool_agent")
|
||||
|
||||
spy_agent = _SessionIdCapturingAgent(id="spy_agent", name="SpyAgent")
|
||||
@@ -393,9 +391,7 @@ async def test_from_response_preserves_service_session_id() -> None:
|
||||
"""from_response hands off a prior agent's full conversation to the next executor.
|
||||
The receiving executor's service_session_id is preserved so the API can continue
|
||||
the conversation using previous_response_id."""
|
||||
tool_agent = _ToolHistoryAgent(
|
||||
id="tool_agent2", name="ToolAgent", summary_text="Done."
|
||||
)
|
||||
tool_agent = _ToolHistoryAgent(id="tool_agent2", name="ToolAgent", summary_text="Done.")
|
||||
tool_exec = AgentExecutor(tool_agent, id="tool_agent2")
|
||||
|
||||
spy_agent = _SessionIdCapturingAgent(id="spy_agent2", name="SpyAgent")
|
||||
@@ -403,11 +399,7 @@ async def test_from_response_preserves_service_session_id() -> None:
|
||||
# Simulate a prior run on the spy executor.
|
||||
spy_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
wf = (
|
||||
WorkflowBuilder(start_executor=tool_exec, output_executors=[spy_exec])
|
||||
.add_edge(tool_exec, spy_exec)
|
||||
.build()
|
||||
)
|
||||
wf = WorkflowBuilder(start_executor=tool_exec, output_executors=[spy_exec]).add_edge(tool_exec, spy_exec).build()
|
||||
|
||||
result = await wf.run("start")
|
||||
assert result.get_outputs() is not None
|
||||
|
||||
@@ -41,7 +41,7 @@ from agent_framework import Agent, SupportsAgentRun
|
||||
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware
|
||||
from agent_framework._sessions import AgentSession
|
||||
from agent_framework._tools import FunctionTool, tool
|
||||
from agent_framework._types import AgentResponse, AgentResponseUpdate, Message
|
||||
from agent_framework._types import AgentResponse, AgentResponseUpdate, Content, Message
|
||||
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from agent_framework._workflows._agent_utils import resolve_agent_id
|
||||
from agent_framework._workflows._checkpoint import CheckpointStorage
|
||||
@@ -267,6 +267,88 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
|
||||
return cloned_agent
|
||||
|
||||
def _persist_pending_approval_function_calls(self) -> None:
|
||||
"""Persist pending approval function calls for stateless provider resumes.
|
||||
|
||||
Handoff workflows force ``store=False`` and replay conversation state from ``_full_conversation``.
|
||||
When a run pauses on function approval, ``AgentExecutor`` returns ``None`` and the assistant
|
||||
function-call message is not returned as an ``AgentResponse``. Without persisting that call, the
|
||||
next turn may submit only a function result, which responses-style APIs reject.
|
||||
"""
|
||||
pending_calls: list[Content] = []
|
||||
for request in self._pending_agent_requests.values():
|
||||
if request.type != "function_approval_request":
|
||||
continue
|
||||
function_call = getattr(request, "function_call", None)
|
||||
if isinstance(function_call, Content) and function_call.type == "function_call":
|
||||
pending_calls.append(function_call)
|
||||
|
||||
if not pending_calls:
|
||||
return
|
||||
|
||||
self._full_conversation.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=pending_calls,
|
||||
author_name=self._agent.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _persist_missing_approved_function_results(
|
||||
self,
|
||||
*,
|
||||
runtime_tool_messages: list[Message],
|
||||
response_messages: list[Message],
|
||||
) -> None:
|
||||
"""Persist fallback function_result entries for approved calls when missing.
|
||||
|
||||
In approval resumes, function invocation can execute approved tools without
|
||||
always surfacing those tool outputs in the returned ``AgentResponse.messages``.
|
||||
For stateless handoff replays, we must keep call/output pairs balanced.
|
||||
"""
|
||||
candidate_results: dict[str, Content] = {}
|
||||
for message in runtime_tool_messages:
|
||||
for content in message.contents:
|
||||
if content.type == "function_result":
|
||||
call_id = getattr(content, "call_id", None)
|
||||
if isinstance(call_id, str) and call_id:
|
||||
candidate_results[call_id] = content
|
||||
continue
|
||||
|
||||
if content.type != "function_approval_response" or not content.approved:
|
||||
continue
|
||||
|
||||
function_call = getattr(content, "function_call", None)
|
||||
call_id = getattr(function_call, "call_id", None) or getattr(content, "id", None)
|
||||
if isinstance(call_id, str) and call_id and call_id not in candidate_results:
|
||||
# Fallback content for approved calls when runtime messages do not include
|
||||
# a concrete function_result payload.
|
||||
candidate_results[call_id] = Content.from_function_result(
|
||||
call_id=call_id,
|
||||
result='{"status":"approved"}',
|
||||
)
|
||||
|
||||
if not candidate_results:
|
||||
return
|
||||
|
||||
observed_result_call_ids: set[str] = set()
|
||||
for message in [*self._full_conversation, *response_messages]:
|
||||
for content in message.contents:
|
||||
if content.type == "function_result" and isinstance(content.call_id, str) and content.call_id:
|
||||
observed_result_call_ids.add(content.call_id)
|
||||
|
||||
missing_call_ids = sorted(set(candidate_results.keys()) - observed_result_call_ids)
|
||||
if not missing_call_ids:
|
||||
return
|
||||
|
||||
self._full_conversation.append(
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[candidate_results[call_id] for call_id in missing_call_ids],
|
||||
author_name=self._agent.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _clone_chat_agent(self, agent: Agent) -> Agent:
|
||||
"""Produce a deep copy of the Agent while preserving runtime configuration."""
|
||||
options = agent.default_options
|
||||
@@ -287,6 +369,10 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
# Disable parallel tool calls to prevent the agent from invoking multiple handoff tools at once.
|
||||
cloned_options: dict[str, Any] = {
|
||||
"allow_multiple_tool_calls": False,
|
||||
# Handoff workflows already manage full conversation context explicitly
|
||||
# across executors. Keep provider-side conversation storage disabled to
|
||||
# avoid stale tool-call state (Responses API previous_response chains).
|
||||
"store": False,
|
||||
"frequency_penalty": options.get("frequency_penalty"),
|
||||
"instructions": options.get("instructions"),
|
||||
"logit_bias": dict(logit_bias) if logit_bias else None,
|
||||
@@ -297,7 +383,6 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
"response_format": options.get("response_format"),
|
||||
"seed": options.get("seed"),
|
||||
"stop": options.get("stop"),
|
||||
"store": options.get("store"),
|
||||
"temperature": options.get("temperature"),
|
||||
"tool_choice": options.get("tool_choice"),
|
||||
"tools": all_tools if all_tools else None,
|
||||
@@ -366,14 +451,44 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate]
|
||||
) -> None:
|
||||
"""Override to support handoff."""
|
||||
incoming_messages = list(self._cache)
|
||||
cleaned_incoming_messages = clean_conversation_for_handoff(incoming_messages)
|
||||
runtime_tool_messages = [
|
||||
message
|
||||
for message in incoming_messages
|
||||
if any(
|
||||
content.type
|
||||
in {
|
||||
"function_result",
|
||||
"function_approval_response",
|
||||
}
|
||||
for content in message.contents
|
||||
)
|
||||
or message.role == "tool"
|
||||
]
|
||||
|
||||
# When the full conversation is empty, it means this is the first run.
|
||||
# Broadcast the initial cache to all other agents. Subsequent runs won't
|
||||
# need this since responses are broadcast after each agent run and user input.
|
||||
if self._is_start_agent and not self._full_conversation:
|
||||
await self._broadcast_messages(self._cache.copy(), cast(WorkflowContext[AgentExecutorRequest], ctx))
|
||||
await self._broadcast_messages(cleaned_incoming_messages, cast(WorkflowContext[AgentExecutorRequest], ctx))
|
||||
|
||||
# Append the cache to the full conversation history
|
||||
self._full_conversation.extend(self._cache)
|
||||
# Persist only cleaned chat history between turns to avoid replaying stale tool calls.
|
||||
self._full_conversation.extend(cleaned_incoming_messages)
|
||||
|
||||
# Always run with full conversation context for request_info resumes.
|
||||
# Keep runtime tool-control messages for this run only (e.g., approval responses).
|
||||
self._cache = list(self._full_conversation)
|
||||
self._cache.extend(runtime_tool_messages)
|
||||
|
||||
# Handoff workflows are orchestrator-stateful and provider-stateless by design.
|
||||
# If an existing session still has a service conversation id, clear it to avoid
|
||||
# replaying stale unresolved tool calls across resumed turns.
|
||||
if (
|
||||
cast(Agent, self._agent).default_options.get("store") is False
|
||||
and self._session.service_session_id is not None
|
||||
):
|
||||
self._session.service_session_id = None
|
||||
|
||||
# Check termination condition before running the agent
|
||||
if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)):
|
||||
@@ -392,17 +507,26 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
|
||||
# A function approval request is issued by the base AgentExecutor
|
||||
if response is None:
|
||||
if cast(Agent, self._agent).default_options.get("store") is False:
|
||||
self._persist_pending_approval_function_calls()
|
||||
# Agent did not complete (e.g., waiting for user input); do not emit response
|
||||
logger.debug("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
|
||||
return
|
||||
|
||||
# Remove function call related content from the agent response for full conversation history
|
||||
# Remove function call related content from the agent response for broadcast.
|
||||
# This prevents replaying stale tool artifacts to other agents.
|
||||
cleaned_response = clean_conversation_for_handoff(response.messages)
|
||||
# Append the agent response to the full conversation history. This list removes
|
||||
# function call related content such that the result stays consistent regardless
|
||||
# of which agent yields the final output.
|
||||
self._full_conversation.extend(cleaned_response)
|
||||
# Broadcast the cleaned response to all other agents
|
||||
|
||||
# For internal tracking, preserve the full response (including function_calls)
|
||||
# in _full_conversation so that Azure OpenAI can match function_calls with
|
||||
# function_results when the workflow resumes after user approvals.
|
||||
self._full_conversation.extend(response.messages)
|
||||
self._persist_missing_approved_function_results(
|
||||
runtime_tool_messages=runtime_tool_messages,
|
||||
response_messages=response.messages,
|
||||
)
|
||||
|
||||
# Broadcast only the cleaned response to other agents (without function_calls/results)
|
||||
await self._broadcast_messages(cleaned_response, cast(WorkflowContext[AgentExecutorRequest], ctx))
|
||||
|
||||
# Check if a handoff was requested
|
||||
@@ -422,6 +546,12 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
self._autonomous_mode_turns = 0 # Reset autonomous mode turn counter on handoff
|
||||
return
|
||||
|
||||
# Re-evaluate termination after appending and broadcasting this response.
|
||||
# Without this check, workflows that become terminal due to the latest assistant
|
||||
# message would still emit request_info and require an unnecessary extra resume.
|
||||
if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)):
|
||||
return
|
||||
|
||||
# Handle case where no handoff was requested
|
||||
if self._autonomous_mode and self._autonomous_mode_turns < self._autonomous_mode_turn_limit:
|
||||
# In autonomous mode, continue running the agent until a handoff is requested
|
||||
@@ -497,22 +627,20 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
last_message = response.messages[-1]
|
||||
for content in last_message.contents:
|
||||
if content.type == "function_result":
|
||||
if not content.result:
|
||||
continue
|
||||
|
||||
parsed_result: dict[str, Any] | None = None
|
||||
if isinstance(content.result, dict):
|
||||
parsed_result = content.result
|
||||
elif isinstance(content.result, str):
|
||||
payload = content.result
|
||||
parsed_payload: dict[str, Any] | None = None
|
||||
if isinstance(payload, dict):
|
||||
parsed_payload = payload
|
||||
elif isinstance(payload, str):
|
||||
try:
|
||||
loaded_result = json.loads(content.result)
|
||||
maybe_payload = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(loaded_result, dict):
|
||||
parsed_result = loaded_result
|
||||
maybe_payload = None
|
||||
if isinstance(maybe_payload, dict):
|
||||
parsed_payload = maybe_payload
|
||||
|
||||
if parsed_result is not None:
|
||||
handoff_target = parsed_result.get(HANDOFF_FUNCTION_RESULT_KEY)
|
||||
if parsed_payload:
|
||||
handoff_target = parsed_payload.get(HANDOFF_FUNCTION_RESULT_KEY)
|
||||
if isinstance(handoff_target, str):
|
||||
return handoff_target
|
||||
else:
|
||||
|
||||
+20
-44
@@ -14,57 +14,33 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message]:
|
||||
"""Remove tool-related content from conversation for clean handoffs.
|
||||
"""Keep only plain text chat history for handoff routing.
|
||||
|
||||
During handoffs, tool calls can cause API errors because:
|
||||
1. Assistant messages with tool_calls must be followed by tool responses
|
||||
2. Tool response messages must follow an assistant message with tool_calls
|
||||
Handoff executors must not replay prior tool-control artifacts (function calls,
|
||||
tool outputs, approval payloads) into future model turns, or providers may reject
|
||||
the next request due to unmatched tool-call state.
|
||||
|
||||
This creates a cleaned copy removing ALL tool-related content.
|
||||
|
||||
Removes:
|
||||
- function_approval_request and function_call from assistant messages
|
||||
- Tool response messages (role="tool")
|
||||
- Messages with only tool calls and no text
|
||||
|
||||
Preserves:
|
||||
- User messages
|
||||
- Assistant messages with text content
|
||||
|
||||
Args:
|
||||
conversation: Original conversation with potential tool content
|
||||
|
||||
Returns:
|
||||
Cleaned conversation safe for handoff routing
|
||||
This helper builds a text-only copy of the conversation:
|
||||
- Drops all non-text content from every message.
|
||||
- Drops messages with no remaining text content.
|
||||
- Preserves original roles and author names for retained text messages.
|
||||
"""
|
||||
cleaned: list[Message] = []
|
||||
for msg in conversation:
|
||||
# Skip tool response messages entirely
|
||||
if msg.role == "tool":
|
||||
# Keep only plain text history for handoff routing. Tool-control content
|
||||
# (function_call/function_result/approval payloads) is runtime-only and
|
||||
# must not be replayed in future model turns.
|
||||
text_parts = [content.text for content in msg.contents if content.type == "text" and content.text]
|
||||
if not text_parts:
|
||||
continue
|
||||
|
||||
# Check for tool-related content
|
||||
has_tool_content = False
|
||||
if msg.contents:
|
||||
has_tool_content = any(
|
||||
content.type in ("function_approval_request", "function_call") for content in msg.contents
|
||||
)
|
||||
|
||||
# If no tool content, keep original
|
||||
if not has_tool_content:
|
||||
cleaned.append(msg)
|
||||
continue
|
||||
|
||||
# Has tool content - only keep if it also has text
|
||||
if msg.text and msg.text.strip():
|
||||
# Create fresh text-only message while preserving additional_properties
|
||||
msg_copy = Message(
|
||||
role=msg.role,
|
||||
text=msg.text,
|
||||
author_name=msg.author_name,
|
||||
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
|
||||
)
|
||||
cleaned.append(msg_copy)
|
||||
msg_copy = Message(
|
||||
role=msg.role,
|
||||
text=" ".join(text_parts),
|
||||
author_name=msg.author_name,
|
||||
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
|
||||
)
|
||||
cleaned.append(msg_copy)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -15,18 +16,21 @@ from agent_framework import (
|
||||
ResponseStream,
|
||||
WorkflowEvent,
|
||||
resolve_agent_id,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._clients import BaseChatClient
|
||||
from agent_framework._middleware import ChatMiddlewareLayer, FunctionInvocationContext, MiddlewareTermination
|
||||
from agent_framework._tools import FunctionInvocationLayer, FunctionTool, tool
|
||||
from agent_framework._tools import FunctionInvocationLayer, FunctionTool
|
||||
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
|
||||
|
||||
from agent_framework_orchestrations._handoff import (
|
||||
HANDOFF_FUNCTION_RESULT_KEY,
|
||||
HandoffAgentExecutor,
|
||||
HandoffConfiguration,
|
||||
_AutoHandoffMiddleware, # pyright: ignore[reportPrivateUsage]
|
||||
get_handoff_tool_name,
|
||||
)
|
||||
from agent_framework_orchestrations._orchestrator_helpers import clean_conversation_for_handoff
|
||||
|
||||
|
||||
class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
@@ -130,6 +134,67 @@ class MockHandoffAgent(Agent):
|
||||
super().__init__(client=MockChatClient(name=name, handoff_to=handoff_to), name=name, id=name)
|
||||
|
||||
|
||||
class ContextAwareRefundClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
"""Mock client that expects prior user context to remain available on resume."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._call_index = 0
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del kwargs
|
||||
del options
|
||||
|
||||
contents = self._next_contents(messages)
|
||||
if stream:
|
||||
return self._build_streaming_response(contents)
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="context-aware")
|
||||
|
||||
return _get()
|
||||
|
||||
def _build_streaming_response(self, contents: list[Content]) -> ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
return ChatResponse.from_updates(updates)
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
def _next_contents(self, messages: Sequence[Message]) -> list[Content]:
|
||||
user_text = " ".join(message.text or "" for message in messages if message.role == "user")
|
||||
order_match = re.search(r"\b(\d{4,12})\b", user_text)
|
||||
order_id = order_match.group(1) if order_match else None
|
||||
asks_refund = any(token in user_text.lower() for token in ("broken", "damaged", "refund", "cracked"))
|
||||
|
||||
if self._call_index == 0:
|
||||
reply = "Refund Agent: Please share your order number."
|
||||
elif self._call_index == 1:
|
||||
if order_id:
|
||||
reply = f"Refund Agent: Thanks, I found order {order_id}. Why do you need the refund?"
|
||||
else:
|
||||
reply = "Refund Agent: I still need your order number."
|
||||
else:
|
||||
if order_id and asks_refund:
|
||||
reply = f"Refund Agent: Got it for order {order_id}. I can proceed with your refund."
|
||||
else:
|
||||
reply = "Refund Agent: I still need your order number."
|
||||
|
||||
self._call_index += 1
|
||||
return [Content.from_text(text=reply)]
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
return [event async for event in stream]
|
||||
|
||||
@@ -168,6 +233,567 @@ async def test_handoff():
|
||||
assert request.source_executor_id == escalation.name
|
||||
|
||||
|
||||
def _latest_request_info_event(events: list[WorkflowEvent]) -> WorkflowEvent[Any]:
|
||||
request_events = [event for event in events if event.type == "request_info"]
|
||||
assert request_events
|
||||
request_event = request_events[-1]
|
||||
assert isinstance(request_event.data, HandoffAgentUserRequest)
|
||||
return request_event
|
||||
|
||||
|
||||
def _request_text(event: WorkflowEvent[Any]) -> str:
|
||||
request_payload = cast(HandoffAgentUserRequest, event.data)
|
||||
messages = request_payload.agent_response.messages
|
||||
assert messages
|
||||
return messages[-1].text or ""
|
||||
|
||||
|
||||
async def test_resume_keeps_prior_user_context_for_same_agent() -> None:
|
||||
"""Ensure same-agent request_info resumes retain prior turn context."""
|
||||
refund_agent = Agent(
|
||||
id="refund_agent",
|
||||
name="refund_agent",
|
||||
client=ContextAwareRefundClient(),
|
||||
)
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[refund_agent], termination_condition=lambda _: False)
|
||||
.with_start_agent(refund_agent)
|
||||
.build()
|
||||
)
|
||||
|
||||
first_events = await _drain(workflow.run("My order arrived damaged.", stream=True))
|
||||
first_request = _latest_request_info_event(first_events)
|
||||
assert "order number" in _request_text(first_request).lower()
|
||||
|
||||
second_events = await _drain(
|
||||
workflow.run(
|
||||
stream=True,
|
||||
responses={first_request.request_id: [Message(role="user", text="Order 2939393")]},
|
||||
)
|
||||
)
|
||||
second_request = _latest_request_info_event(second_events)
|
||||
second_text = _request_text(second_request).lower()
|
||||
assert "order 2939393" in second_text
|
||||
assert "order number" not in second_text
|
||||
|
||||
third_events = await _drain(
|
||||
workflow.run(
|
||||
stream=True,
|
||||
responses={second_request.request_id: [Message(role="user", text="It arrived broken and unusable.")]},
|
||||
)
|
||||
)
|
||||
third_request = _latest_request_info_event(third_events)
|
||||
third_text = _request_text(third_request).lower()
|
||||
assert "order 2939393" in third_text
|
||||
assert "order number" not in third_text
|
||||
|
||||
|
||||
async def test_tool_approval_responses_are_not_replayed_from_history() -> None:
|
||||
"""Ensure persisted history does not re-execute previously approved tool calls."""
|
||||
execution_count = 0
|
||||
|
||||
@tool(name="submit_refund_counted", approval_mode="always_require")
|
||||
def submit_refund_counted() -> str:
|
||||
nonlocal execution_count
|
||||
execution_count += 1
|
||||
return "ok"
|
||||
|
||||
class ApprovalReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._call_index = 0
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del messages
|
||||
del options
|
||||
del kwargs
|
||||
|
||||
if self._call_index == 0:
|
||||
contents = [
|
||||
Content.from_function_call(
|
||||
call_id="refund-call-1",
|
||||
name="submit_refund_counted",
|
||||
arguments={},
|
||||
)
|
||||
]
|
||||
elif self._call_index == 1:
|
||||
contents = [Content.from_text(text="Refund approved and recorded.")]
|
||||
else:
|
||||
contents = [Content.from_text(text="No additional tool work needed.")]
|
||||
self._call_index += 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=contents)],
|
||||
response_id="approval-replay",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
agent = Agent(
|
||||
id="refund_agent",
|
||||
name="refund_agent",
|
||||
client=ApprovalReplayClient(),
|
||||
tools=[submit_refund_counted],
|
||||
)
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[agent], termination_condition=lambda _: False).with_start_agent(agent).build()
|
||||
)
|
||||
|
||||
first_events = await _drain(workflow.run("start", stream=True))
|
||||
first_requests = [event for event in first_events if event.type == "request_info"]
|
||||
assert first_requests
|
||||
first_request = first_requests[-1]
|
||||
assert isinstance(first_request.data, Content)
|
||||
approval_response = first_request.data.to_function_approval_response(approved=True)
|
||||
|
||||
second_events = await _drain(workflow.run(stream=True, responses={first_request.request_id: approval_response}))
|
||||
second_request = _latest_request_info_event(second_events)
|
||||
|
||||
await _drain(
|
||||
workflow.run(
|
||||
stream=True,
|
||||
responses={second_request.request_id: [Message(role="user", text="Thanks, what's next?")]},
|
||||
)
|
||||
)
|
||||
|
||||
assert execution_count == 1
|
||||
|
||||
|
||||
async def test_handoff_resume_preserves_approval_function_call_for_stateless_runs() -> None:
|
||||
"""Approval resume turns must replay matching function calls when store=False."""
|
||||
|
||||
@tool(name="submit_refund", approval_mode="always_require")
|
||||
def submit_refund() -> str:
|
||||
return "ok"
|
||||
|
||||
class StrictStatelessApprovalClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._call_index = 0
|
||||
self.resume_validated = False
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del options
|
||||
del kwargs
|
||||
|
||||
if self._call_index == 0:
|
||||
contents = [
|
||||
Content.from_function_call(
|
||||
call_id="refund-call-1",
|
||||
name="submit_refund",
|
||||
arguments={},
|
||||
)
|
||||
]
|
||||
else:
|
||||
function_call_ids = {
|
||||
content.call_id
|
||||
for message in messages
|
||||
for content in message.contents
|
||||
if content.type == "function_call" and content.call_id
|
||||
}
|
||||
function_result_ids = {
|
||||
content.call_id
|
||||
for message in messages
|
||||
for content in message.contents
|
||||
if content.type == "function_result" and content.call_id
|
||||
}
|
||||
missing_call_ids = sorted(function_result_ids - function_call_ids)
|
||||
if missing_call_ids:
|
||||
raise AssertionError(
|
||||
f"No tool call found for function call output with call_id {missing_call_ids[0]}."
|
||||
)
|
||||
self.resume_validated = True
|
||||
contents = [Content.from_text(text="Refund submitted.")]
|
||||
|
||||
self._call_index += 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=contents)],
|
||||
response_id="strict-stateless",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
client = StrictStatelessApprovalClient()
|
||||
agent = Agent(
|
||||
id="refund_agent",
|
||||
name="refund_agent",
|
||||
client=client,
|
||||
tools=[submit_refund],
|
||||
)
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[agent], termination_condition=lambda _: False).with_start_agent(agent).build()
|
||||
)
|
||||
|
||||
first_events = await _drain(workflow.run("start", stream=True))
|
||||
approval_requests = [
|
||||
event for event in first_events if event.type == "request_info" and isinstance(event.data, Content)
|
||||
]
|
||||
assert approval_requests
|
||||
first_request = approval_requests[0]
|
||||
|
||||
approval_response = first_request.data.to_function_approval_response(True)
|
||||
await _drain(workflow.run(stream=True, responses={first_request.request_id: approval_response}))
|
||||
|
||||
assert client.resume_validated is True
|
||||
|
||||
|
||||
async def test_handoff_replay_serializes_handoff_function_results() -> None:
|
||||
"""Returning to the same agent must not replay dict tool outputs."""
|
||||
|
||||
class ReplaySafeHandoffClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self, name: str, handoff_sequence: list[str | None]) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._name = name
|
||||
self._handoff_sequence = handoff_sequence
|
||||
self._call_index = 0
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del options
|
||||
del kwargs
|
||||
|
||||
for message in messages:
|
||||
for content in message.contents:
|
||||
if content.type == "function_result" and isinstance(content.result, dict):
|
||||
raise AssertionError("Expected replayed function_result payloads to be JSON strings.")
|
||||
|
||||
handoff_to = (
|
||||
self._handoff_sequence[self._call_index] if self._call_index < len(self._handoff_sequence) else None
|
||||
)
|
||||
call_id = f"{self._name}-handoff-{self._call_index}" if handoff_to else None
|
||||
contents = _build_reply_contents(self._name, handoff_to, call_id)
|
||||
self._call_index += 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="replay-safe")
|
||||
|
||||
return _get()
|
||||
|
||||
triage = Agent(
|
||||
id="triage",
|
||||
name="triage",
|
||||
client=ReplaySafeHandoffClient(name="triage", handoff_sequence=["specialist", None]),
|
||||
)
|
||||
specialist = Agent(
|
||||
id="specialist",
|
||||
name="specialist",
|
||||
client=ReplaySafeHandoffClient(name="specialist", handoff_sequence=["triage"]),
|
||||
)
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[triage, specialist], termination_condition=lambda _: False)
|
||||
.with_start_agent(triage)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("start", stream=True))
|
||||
requests = [event for event in events if event.type == "request_info"]
|
||||
assert requests
|
||||
assert requests[-1].source_executor_id == triage.name
|
||||
|
||||
|
||||
async def test_handoff_resume_preserves_approved_tool_output_for_stateless_runs() -> None:
|
||||
"""Approved calls must keep function_call/function_result pairs for later replays."""
|
||||
submit_call_id = "call_submit_refund_approved"
|
||||
|
||||
@tool(name="submit_refund", approval_mode="always_require")
|
||||
def submit_refund() -> str:
|
||||
return "submitted"
|
||||
|
||||
class RefundReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._call_index = 0
|
||||
self.resume_validated = False
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del options
|
||||
del kwargs
|
||||
|
||||
if self._call_index == 0:
|
||||
contents = [Content.from_function_call(call_id=submit_call_id, name="submit_refund", arguments={})]
|
||||
elif self._call_index == 1:
|
||||
contents = _build_reply_contents("refund_agent", "order_agent", "refund-order-handoff-1")
|
||||
else:
|
||||
function_call_ids = {
|
||||
content.call_id
|
||||
for message in messages
|
||||
for content in message.contents
|
||||
if content.type == "function_call" and content.call_id
|
||||
}
|
||||
function_result_ids = {
|
||||
content.call_id
|
||||
for message in messages
|
||||
for content in message.contents
|
||||
if content.type == "function_result" and content.call_id
|
||||
}
|
||||
if submit_call_id in function_call_ids and submit_call_id not in function_result_ids:
|
||||
raise AssertionError(f"No tool output found for function call {submit_call_id}.")
|
||||
self.resume_validated = True
|
||||
contents = [Content.from_text(text="Refund agent resumed.")]
|
||||
|
||||
self._call_index += 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=contents)],
|
||||
response_id="refund-replay",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
class OrderReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
self._call_index = 0
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del messages
|
||||
del options
|
||||
del kwargs
|
||||
|
||||
if self._call_index == 0:
|
||||
contents = [Content.from_text(text="Would you like a replacement or a refund?")]
|
||||
else:
|
||||
contents = _build_reply_contents("order_agent", "refund_agent", "order-refund-handoff-1")
|
||||
self._call_index += 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="order-replay")
|
||||
|
||||
return _get()
|
||||
|
||||
refund_client = RefundReplayClient()
|
||||
refund_agent = Agent(
|
||||
id="refund_agent",
|
||||
name="refund_agent",
|
||||
client=refund_client,
|
||||
tools=[submit_refund],
|
||||
)
|
||||
order_agent = Agent(
|
||||
id="order_agent",
|
||||
name="order_agent",
|
||||
client=OrderReplayClient(),
|
||||
)
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[refund_agent, order_agent], termination_condition=lambda _: False)
|
||||
.with_start_agent(refund_agent)
|
||||
.build()
|
||||
)
|
||||
|
||||
first_events = await _drain(workflow.run("start", stream=True))
|
||||
approval_requests = [
|
||||
event for event in first_events if event.type == "request_info" and isinstance(event.data, Content)
|
||||
]
|
||||
assert approval_requests
|
||||
approval_request = approval_requests[-1]
|
||||
approval_response = approval_request.data.to_function_approval_response(True)
|
||||
|
||||
second_events = await _drain(workflow.run(stream=True, responses={approval_request.request_id: approval_response}))
|
||||
order_request = _latest_request_info_event(second_events)
|
||||
assert order_request.source_executor_id == order_agent.name
|
||||
|
||||
await _drain(
|
||||
workflow.run(
|
||||
stream=True,
|
||||
responses={order_request.request_id: [Message(role="user", text="Please continue with refund.")]},
|
||||
)
|
||||
)
|
||||
|
||||
assert refund_client.resume_validated is True
|
||||
|
||||
|
||||
def test_handoff_clone_disables_provider_side_storage() -> None:
|
||||
"""Handoff executors should force store=False to avoid stale provider call state."""
|
||||
triage = MockHandoffAgent(name="triage")
|
||||
workflow = HandoffBuilder(participants=[triage]).with_start_agent(triage).build()
|
||||
|
||||
executor = workflow.executors[resolve_agent_id(triage)]
|
||||
assert isinstance(executor, HandoffAgentExecutor)
|
||||
assert executor._agent.default_options.get("store") is False
|
||||
|
||||
|
||||
async def test_handoff_clears_stale_service_session_id_before_run() -> None:
|
||||
"""Stale service session IDs must be dropped before each handoff agent turn."""
|
||||
triage = MockHandoffAgent(name="triage", handoff_to="specialist")
|
||||
specialist = MockHandoffAgent(name="specialist")
|
||||
workflow = HandoffBuilder(participants=[triage, specialist]).with_start_agent(triage).build()
|
||||
|
||||
triage_executor = workflow.executors[resolve_agent_id(triage)]
|
||||
assert isinstance(triage_executor, HandoffAgentExecutor)
|
||||
triage_executor._session.service_session_id = "resp_stale_value"
|
||||
|
||||
await _drain(workflow.run("My order is damaged", stream=True))
|
||||
|
||||
assert triage_executor._session.service_session_id is None
|
||||
|
||||
|
||||
def test_clean_conversation_for_handoff_keeps_text_only_history() -> None:
|
||||
"""Tool-control messages must be excluded from persisted handoff history."""
|
||||
function_call = Content.from_function_call(
|
||||
call_id="handoff-call-1",
|
||||
name="handoff_to_refund_agent",
|
||||
arguments={"context": "route to refund"},
|
||||
)
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval-1",
|
||||
function_call=function_call,
|
||||
)
|
||||
|
||||
conversation = [
|
||||
Message(role="user", text="My order arrived damaged."),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
function_call,
|
||||
Content.from_text(text="Triage Agent: Routing you to Refund."),
|
||||
],
|
||||
),
|
||||
Message(role="tool", contents=[Content.from_function_result(call_id="handoff-call-1", result="ok")]),
|
||||
Message(role="user", contents=[approval_response]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="handoff-call-2", name="handoff_to_order_agent")],
|
||||
),
|
||||
]
|
||||
|
||||
cleaned = clean_conversation_for_handoff(conversation)
|
||||
assert [message.role for message in cleaned] == ["user", "assistant"]
|
||||
assert [message.text for message in cleaned] == [
|
||||
"My order arrived damaged.",
|
||||
"Triage Agent: Routing you to Refund.",
|
||||
]
|
||||
|
||||
|
||||
def test_persist_missing_approved_function_results_handles_runtime_and_fallback_outputs() -> None:
|
||||
"""Persisted history should retain approved call outputs across runtime shapes."""
|
||||
agent = MockHandoffAgent(name="triage")
|
||||
executor = HandoffAgentExecutor(agent, handoffs=[])
|
||||
|
||||
call_with_runtime_result = "call-runtime-result"
|
||||
call_with_approval_only = "call-approval-only"
|
||||
|
||||
executor._full_conversation = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id=call_with_runtime_result, name="submit_refund", arguments={}),
|
||||
Content.from_function_call(call_id=call_with_approval_only, name="submit_refund", arguments={}),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id=call_with_approval_only,
|
||||
function_call=Content.from_function_call(call_id=call_with_approval_only, name="submit_refund", arguments={}),
|
||||
)
|
||||
runtime_messages = [
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id=call_with_runtime_result, result='{"submitted":true}')],
|
||||
),
|
||||
Message(role="user", contents=[approval_response]),
|
||||
]
|
||||
|
||||
executor._persist_missing_approved_function_results(runtime_tool_messages=runtime_messages, response_messages=[])
|
||||
|
||||
persisted_tool_messages = [message for message in executor._full_conversation if message.role == "tool"]
|
||||
assert persisted_tool_messages
|
||||
persisted_results = [
|
||||
content
|
||||
for message in persisted_tool_messages
|
||||
for content in message.contents
|
||||
if content.type == "function_result" and content.call_id
|
||||
]
|
||||
result_by_call_id = {content.call_id: content.result for content in persisted_results}
|
||||
assert result_by_call_id[call_with_runtime_result] == '{"submitted":true}'
|
||||
assert result_by_call_id[call_with_approval_only] == '{"status":"approved"}'
|
||||
|
||||
|
||||
async def test_autonomous_mode_yields_output_without_user_request():
|
||||
"""Ensure autonomous interaction mode yields output without requesting user input."""
|
||||
triage = MockHandoffAgent(name="triage", handoff_to="specialist")
|
||||
@@ -278,6 +904,61 @@ async def test_handoff_async_termination_condition() -> None:
|
||||
assert termination_call_count > 0
|
||||
|
||||
|
||||
async def test_handoff_terminates_without_request_info_when_latest_response_meets_condition() -> None:
|
||||
"""Termination triggered by the latest assistant response should not emit request_info."""
|
||||
|
||||
class FinalizingClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
BaseChatClient.__init__(self)
|
||||
|
||||
def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: Sequence[Message],
|
||||
stream: bool,
|
||||
options: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
del messages, options, kwargs
|
||||
contents = [Content.from_text(text="Replacement request submitted. Case complete.")]
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates))
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="finalizing")
|
||||
|
||||
return _get()
|
||||
|
||||
agent = Agent(id="order_agent", name="order_agent", client=FinalizingClient())
|
||||
workflow = (
|
||||
HandoffBuilder(
|
||||
participants=[agent],
|
||||
termination_condition=lambda conv: any(
|
||||
message.role == "assistant" and "case complete." in (message.text or "").lower() for message in conv
|
||||
),
|
||||
)
|
||||
.with_start_agent(agent)
|
||||
.build()
|
||||
)
|
||||
|
||||
events = await _drain(workflow.run("ship replacement", stream=True))
|
||||
|
||||
requests = [event for event in events if event.type == "request_info"]
|
||||
assert not requests
|
||||
|
||||
outputs = [event for event in events if event.type == "output"]
|
||||
assert outputs
|
||||
conversation_outputs = [event for event in outputs if isinstance(event.data, list)]
|
||||
assert len(conversation_outputs) == 1
|
||||
|
||||
|
||||
async def test_tool_choice_preserved_from_agent_config():
|
||||
"""Verify that agent-level tool_choice configuration is preserved and not overridden."""
|
||||
# Create a mock chat client that records the tool_choice used
|
||||
|
||||
Reference in New Issue
Block a user