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:
Evan Mattson
2026-02-23 11:59:56 +00:00
committed by GitHub
co-authored by Eduard van Valkenburg
parent b1c7c7c844
commit d8b9409e96
60 changed files with 8349 additions and 512 deletions
@@ -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.
@@ -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)