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:
co-authored by
Eduard van Valkenburg
parent
b1c7c7c844
commit
d8b9409e96
@@ -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)
|
||||
Reference in New Issue
Block a user