chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI protocol integration for Agent Framework."""
import importlib.metadata
from ._agent import AgentFrameworkAgent
from ._client import AGUIChatClient
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._snapshots import (
DEFAULT_MAX_THREAD_SNAPSHOTS,
AGUIThreadID,
AGUIThreadSnapshot,
AGUIThreadSnapshotStore,
InMemoryAGUIThreadSnapshotStore,
SnapshotScope,
SnapshotScopeResolver,
)
from ._state import state_update
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
# Default OpenAPI tags for AG-UI endpoints
DEFAULT_TAGS = ["AG-UI"]
__all__ = [
"AgentFrameworkAgent",
"AgentFrameworkWorkflow",
"WorkflowFactory",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIChatOptions",
"AGUIEventConverter",
"AGUIHttpService",
"AGUIRequest",
"AGUIThreadID",
"AGUIThreadSnapshot",
"AGUIThreadSnapshotStore",
"AgentState",
"InMemoryAGUIThreadSnapshotStore",
"PredictStateConfig",
"RunMetadata",
"SnapshotScope",
"SnapshotScopeResolver",
"DEFAULT_MAX_THREAD_SNAPSHOTS",
"DEFAULT_TAGS",
"state_update",
"__version__",
]
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.
"""AgentFrameworkAgent wrapper for AG-UI protocol."""
from collections.abc import AsyncGenerator
from typing import Any, cast
from ag_ui.core import BaseEvent
from agent_framework import SupportsAgentRun
from ._agent_run import PendingApprovalEntry, PendingApprovalKey, run_agent_stream
from ._approval_state import InMemoryAGUIApprovalStateStore
from ._snapshots import AGUIThreadSnapshotStore
class AgentConfig:
"""Configuration for agent wrapper."""
def __init__(
self,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
use_service_session: bool = False,
require_confirmation: bool = True,
snapshot_store: AGUIThreadSnapshotStore | None = None,
):
"""Initialize agent configuration.
Args:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
use_service_session: Whether the agent session is service-managed
require_confirmation: Whether predictive updates require user confirmation before applying
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
self.state_schema = self._normalize_state_schema(state_schema)
self.predict_state_config = predict_state_config or {}
self.use_service_session = use_service_session
self.require_confirmation = require_confirmation
self.snapshot_store = snapshot_store
@staticmethod
def _normalize_state_schema(state_schema: Any | None) -> dict[str, Any]:
"""Accept dict or Pydantic model/class and return a properties dict."""
if state_schema is None:
return {}
if isinstance(state_schema, dict):
return cast(dict[str, Any], state_schema)
base_model_type: type[Any] | None
try:
from pydantic import BaseModel as ImportedBaseModel
base_model_type = ImportedBaseModel
except Exception: # pragma: no cover
base_model_type = None
if base_model_type is not None and isinstance(state_schema, base_model_type):
schema_dict = state_schema.__class__.model_json_schema()
return schema_dict.get("properties", {}) or {}
if base_model_type is not None and isinstance(state_schema, type) and issubclass(state_schema, base_model_type):
schema_dict = state_schema.model_json_schema() # type: ignore[union-attr]
return schema_dict.get("properties", {}) or {} # type: ignore
return {}
class AgentFrameworkAgent:
"""Wraps Agent Framework agents for AG-UI protocol compatibility.
Translates between Agent Framework's SupportsAgentRun and AG-UI's event-based
protocol. Follows a simple linear flow: RunStarted -> content events -> RunFinished.
"""
def __init__(
self,
agent: SupportsAgentRun,
name: str | None = None,
description: str | None = None,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
use_service_session: bool = False,
snapshot_store: AGUIThreadSnapshotStore | None = None,
):
"""Initialize the AG-UI compatible agent wrapper.
Args:
agent: The Agent Framework agent to wrap
name: Optional name for the agent
description: Optional description
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
require_confirmation: Whether predictive updates require user confirmation before applying
use_service_session: Whether the agent session is service-managed
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
self.agent = agent
self.name = name or getattr(agent, "name", "agent")
self.description = description or getattr(agent, "description", "")
self.config = AgentConfig(
state_schema=state_schema,
predict_state_config=predict_state_config,
use_service_session=use_service_session,
require_confirmation=require_confirmation,
snapshot_store=snapshot_store,
)
# Server-side Approval State. Populated when approval requests are emitted
# and consumed when resume decisions arrive.
self._approval_state_store = InMemoryAGUIApprovalStateStore()
self._pending_approvals = cast(
dict[PendingApprovalKey, PendingApprovalEntry],
self._approval_state_store.pending_approvals,
)
@property
def snapshot_store(self) -> AGUIThreadSnapshotStore | None:
"""Configured AG-UI Thread Snapshot store, if any."""
return self.config.snapshot_store
async def run(
self,
input_data: dict[str, Any],
) -> AsyncGenerator[BaseEvent, None]:
"""Run the wrapped agent and yield AG-UI events.
Args:
input_data: The AG-UI run input containing messages, state, etc.
Yields:
AG-UI events
"""
async for event in run_agent_stream(
input_data,
self.agent,
self.config,
pending_approvals=self._pending_approvals,
approval_state_store=self._approval_state_store,
):
yield event
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Server-side AG-UI approval state storage."""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
ApprovalScope = str
"""Application-defined scope for server-side AG-UI Approval State."""
DEFAULT_MAX_APPROVAL_STATES = 10_000
_APPROVAL_SCOPE_INPUT_KEY = "__ag_ui_approval_scope"
_APPROVAL_THREAD_SEPARATOR = "\x1f"
def approval_state_thread_id(*, scope: object | None, thread_id: str) -> str:
"""Return the storage thread key for Approval State.
``None`` is the only unscoped value. A provided scope must be a non-empty
string so accidental empty or malformed scopes cannot collapse into the
unscoped namespace.
"""
if scope is None:
return thread_id
if not isinstance(scope, str) or not scope:
raise ValueError("scope must be a non-empty string when provided.")
return f"{scope}{_APPROVAL_THREAD_SEPARATOR}{thread_id}"
class InMemoryAGUIApprovalStateStore:
"""Bounded process-local server-side store for AG-UI Approval State.
The default store keeps only pending approval entries. It does not store
general ``AgentSession.state`` or AG-UI Thread Snapshots.
"""
def __init__(self, *, max_entries: int = DEFAULT_MAX_APPROVAL_STATES) -> None:
"""Initialize the process-local Approval State store.
Keyword Args:
max_entries: Maximum pending approval entries to retain.
Raises:
ValueError: If ``max_entries`` is less than 1.
"""
if max_entries < 1:
raise ValueError("max_entries must be greater than 0.")
self.max_entries = max_entries
self.pending_approvals: OrderedDict[tuple[str, str], Any] = OrderedDict()
self.tool_approval_states: OrderedDict[str, dict[str, Any]] = OrderedDict()
def evict_oldest(self) -> None:
"""Evict oldest pending approval entries until the store is within bounds."""
while len(self.pending_approvals) > self.max_entries:
self.pending_approvals.popitem(last=False)
while len(self.tool_approval_states) > self.max_entries:
self.tool_approval_states.popitem(last=False)
@@ -0,0 +1,468 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Chat Client implementation."""
from __future__ import annotations
import json
import logging
import sys
import uuid
from collections.abc import AsyncIterable, Awaitable, Mapping, MutableSequence, Sequence
from functools import wraps
from typing import TYPE_CHECKING, Any, Generic, TypedDict, cast
import httpx
from agent_framework import (
BaseChatClient,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionTool,
Message,
ResponseStream,
)
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from agent_framework.observability import ChatTelemetryLayer
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService, _serialize_available_interrupts, _serialize_resume
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
from typing_extensions import Self, TypedDict # pragma: no cover
if TYPE_CHECKING:
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes
from ._types import AGUIChatOptions
logger: logging.Logger = logging.getLogger("agent_framework.ag_ui")
def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None:
"""Replace server_function_call instances with their underlying call content."""
for idx, content in enumerate(contents):
if content.type == "server_function_call": # type: ignore[union-attr]
contents[idx] = content.function_call # type: ignore[assignment, union-attr]
BaseChatClientT = TypeVar("BaseChatClientT", bound=type[BaseChatClient[Any]])
AGUIChatOptionsT = TypeVar(
"AGUIChatOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="AGUIChatOptions",
covariant=True,
)
def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClientT:
"""Class decorator that unwraps server-side function calls after tool handling."""
original_get_response = client.get_response
@wraps(original_get_response)
def response_wrapper(
self, *args: Any, stream: bool = False, **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
stream_response = original_get_response(self, *args, stream=True, **kwargs)
if isinstance(stream_response, ResponseStream):
return stream_response.with_transform_hook(_map_update)
return ResponseStream(_stream_wrapper_impl(stream_response))
return _response_wrapper_impl(self, original_get_response, *args, **kwargs)
async def _response_wrapper_impl(self, original_func: Any, *args: Any, **kwargs: Any) -> ChatResponse:
"""Non-streaming wrapper implementation."""
response = await original_func(self, *args, stream=False, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], message.contents))
return response
async def _stream_wrapper_impl(stream: Any) -> AsyncIterable[ChatResponseUpdate]:
"""Streaming wrapper implementation."""
if isinstance(stream, Awaitable):
stream = await stream
async for update in stream:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
yield update
def _map_update(update: ChatResponseUpdate) -> ChatResponseUpdate:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
return update
client.get_response = response_wrapper # type: ignore[assignment]
return client
@_apply_server_function_call_unwrap
class AGUIChatClient(
FunctionInvocationLayer[AGUIChatOptionsT],
ChatMiddlewareLayer[AGUIChatOptionsT],
ChatTelemetryLayer[AGUIChatOptionsT],
BaseChatClient[AGUIChatOptionsT],
Generic[AGUIChatOptionsT],
):
"""Chat client for communicating with AG-UI compliant servers.
This client implements the BaseChatClient interface and automatically handles:
- Thread ID management for conversation continuity
- State synchronization between client and server
- Server-Sent Events (SSE) streaming
- Event conversion to Agent Framework types
- MiddlewareTypes, telemetry, and function invocation support
Important: Message History Management
This client sends exactly the messages it receives to the server. It does NOT
automatically maintain conversation history. The server must handle history via thread_id.
For stateless servers: Use Agent wrapper which will send full message history on each
request. However, even with Agent, the server must echo back all context for the
agent to maintain history across turns.
Important: Tool Handling (Hybrid Execution - matches .NET)
1. Client tool metadata sent to server - LLM knows about both client and server tools
2. Server has its own tools that execute server-side
3. When LLM calls a client tool, function invocation executes it locally
4. Both client and server tools work together (hybrid pattern)
The wrapping Agent's function invocation handles client tool execution
automatically when the server's LLM decides to call them.
Examples:
Direct usage (server manages thread history):
.. code-block:: python
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
# First message - thread ID auto-generated
response = await client.get_response("Hello!")
thread_id = response.additional_properties.get("thread_id")
# Second message - server retrieves history using thread_id
response2 = await client.get_response(
"How are you?",
metadata={"thread_id": thread_id}
)
Recommended usage with Agent (client manages history):
.. code-block:: python
from agent_framework import Agent
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = Agent(name="assistant", client=client)
session = agent.create_session()
# Agent automatically maintains history and sends full context
response = await agent.run("Hello!", session=session)
response2 = await agent.run("How are you?", session=session)
Streaming usage:
.. code-block:: python
async for update in client.get_response("Tell me a story", stream=True):
if update.contents:
for content in update.contents:
if hasattr(content, "text"):
print(content.text, end="", flush=True)
Context manager:
.. code-block:: python
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
response = await client.get_response("Hello!")
print(response.messages[0].text)
Using custom ChatOptions with type safety:
.. code-block:: python
from typing import TypedDict
from agent_framework_ag_ui import AGUIChatClient, AGUIChatOptions
class MyOptions(AGUIChatOptions, total=False):
my_custom_option: str
client: AGUIChatClient[MyOptions] = AGUIChatClient(endpoint="http://localhost:8888/")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
OTEL_PROVIDER_NAME = "agui"
def __init__(
self,
*,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
) -> None:
"""Initialize the AG-UI chat client.
Args:
endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx.AsyncClient instance. If None, one will be created.
timeout: Request timeout in seconds (default: 60.0)
additional_properties: Additional properties to store
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
"""
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
self._http_service = AGUIHttpService(
endpoint=endpoint,
http_client=http_client,
timeout=timeout,
)
async def close(self) -> None:
"""Close the HTTP client."""
await self._http_service.close()
async def __aenter__(self) -> Self:
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager."""
await self.close()
def _register_server_tool_placeholder(self, tool_name: str) -> None:
"""Register a declaration-only placeholder so function invocation skips execution."""
config = getattr(self, "function_invocation_configuration", None)
if not isinstance(config, dict):
return
additional_tools = list(config.get("additional_tools", []))
if any(getattr(tool, "name", None) == tool_name for tool in additional_tools):
return
placeholder: FunctionTool = FunctionTool(
name=tool_name,
description="Server-managed tool placeholder (AG-UI)",
func=None,
)
additional_tools.append(placeholder)
config["additional_tools"] = additional_tools
registered: set[str] = getattr(self, "_registered_server_tools", set())
registered.add(tool_name)
self._registered_server_tools = registered
logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}")
def _extract_state_from_messages(self, messages: Sequence[Message]) -> tuple[list[Message], dict[str, Any] | None]:
"""Extract state from last message if present.
Args:
messages: List of chat messages
Returns:
Tuple of (messages_without_state, state_dict)
"""
if not messages:
return list(messages), None
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, Content) and content.type == "data" and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"): # type: ignore[union-attr]
import base64
encoded_data = uri.split(",", 1)[1] # type: ignore[union-attr]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
messages_without_state = list(messages[:-1]) if len(messages) > 1 else []
return messages_without_state, state
except (json.JSONDecodeError, ValueError, KeyError) as e:
logger.warning(f"Failed to extract state from message: {e}")
return list(messages), None
def _convert_messages_to_agui_format(self, messages: list[Message]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of Message objects
Returns:
List of AG-UI formatted message dictionaries
"""
return agent_framework_messages_to_agui(messages)
def _get_thread_id(self, options: Mapping[str, Any]) -> str:
"""Get or generate thread ID from chat options.
Args:
options: Chat options containing metadata
Returns:
Thread ID string
"""
thread_id = None
metadata = options.get("metadata")
if metadata:
thread_id = metadata.get("thread_id")
if not thread_id:
thread_id = f"thread_{uuid.uuid4().hex}"
return thread_id
@override
def _inner_get_response(
self,
*,
messages: Sequence[Message],
stream: bool = False,
options: Mapping[str, Any],
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
"""Internal method to get non-streaming response.
Keyword Args:
messages: List of chat messages
stream: Whether to stream the response.
options: Chat options for the request
**kwargs: Additional keyword arguments
Returns:
ChatResponse object
"""
if stream:
return ResponseStream(
self._streaming_impl(
messages=messages,
options=options,
**kwargs,
),
finalizer=ChatResponse.from_updates,
)
async def _get_response() -> ChatResponse:
return await ChatResponse.from_update_generator(
self._streaming_impl(
messages=messages,
options=options,
**kwargs,
)
)
return _get_response()
async def _streaming_impl(
self,
*,
messages: Sequence[Message],
options: Mapping[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Internal method to get streaming response.
Keyword Args:
messages: Sequence of chat messages
options: Chat options for the request
**kwargs: Additional keyword arguments
Yields:
ChatResponseUpdate objects
"""
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(options)
run_id = f"run_{uuid.uuid4().hex}"
agui_messages = self._convert_messages_to_agui_format(messages_to_send)
# Send client tools to server so LLM knows about them
# Client tools execute via Agent's function invocation wrapper
agui_tools = convert_tools_to_agui_format(options.get("tools"))
# Build set of client tool names (matches .NET clientToolSet)
# Used to distinguish client vs server tools in response stream
client_tool_set: set[str] = set()
tools = options.get("tools")
if tools:
for tool in tools:
if hasattr(tool, "name"):
client_tool_set.add(tool.name)
self._last_client_tool_set = client_tool_set
logger.debug(
"[AGUIChatClient] Preparing request",
extra={
"thread_id": thread_id,
"run_id": run_id,
"client_tools": list(client_tool_set),
"messages": [msg.text for msg in messages_to_send if msg.text],
},
)
logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}")
converter = AGUIEventConverter()
available_interrupts = options.get("available_interrupts", options.get("availableInterrupts"))
async for event in self._http_service.post_run(
thread_id=thread_id,
run_id=run_id,
messages=agui_messages,
state=state,
tools=agui_tools,
available_interrupts=_serialize_available_interrupts(cast(Sequence[Any] | None, available_interrupts)),
resume=_serialize_resume(options.get("resume")),
):
logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}")
update = converter.convert_event(event)
if update is not None:
logger.debug(
"[AGUIChatClient] Converted update",
extra={"role": update.role, "contents": [type(c).__name__ for c in update.contents]},
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if content.type == "function_call":
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
)
if content.name in client_tool_set:
# Client tool - let function invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
else:
# Server tool - wrap so function invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name) # type: ignore[arg-type]
update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore
yield update
@@ -0,0 +1,257 @@
# Copyright (c) Microsoft. All rights reserved.
"""FastAPI endpoint creation for AG-UI agents."""
from __future__ import annotations
import copy
import logging
from collections.abc import AsyncGenerator, Sequence
from inspect import isawaitable
from typing import Any, cast
from ag_ui.core import RunErrorEvent
from ag_ui.encoder import EventEncoder
from agent_framework import SupportsAgentRun, Workflow
from fastapi import FastAPI, HTTPException
from fastapi.params import Depends
from fastapi.responses import Response, StreamingResponse
from ._agent import AgentFrameworkAgent
from ._approval_state import _APPROVAL_SCOPE_INPUT_KEY
from ._snapshots import (
_DEFAULT_STATE_INPUT_KEY,
_SNAPSHOT_SCOPE_INPUT_KEY,
AGUIThreadSnapshotStore,
SnapshotScopeResolver,
)
from ._types import AGUIRequest
from ._workflow import AgentFrameworkWorkflow
logger = logging.getLogger(__name__)
_KEEPALIVE_COMMENT = "keepalive"
def _get_snapshot_store(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
) -> AGUIThreadSnapshotStore | None:
if isinstance(protocol_runner, AgentFrameworkAgent):
return protocol_runner.config.snapshot_store
return protocol_runner.snapshot_store
def _set_snapshot_store(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
snapshot_store: AGUIThreadSnapshotStore,
) -> None:
if isinstance(protocol_runner, AgentFrameworkAgent):
protocol_runner.config.snapshot_store = snapshot_store
return
protocol_runner.snapshot_store = snapshot_store
def _configure_snapshot_persistence(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
*,
snapshot_store: AGUIThreadSnapshotStore | None,
snapshot_scope_resolver: SnapshotScopeResolver | None,
) -> None:
existing_snapshot_store = _get_snapshot_store(protocol_runner)
if snapshot_store is not None:
if existing_snapshot_store is not None and existing_snapshot_store is not snapshot_store:
raise ValueError("snapshot_store is already configured on the AG-UI runner.")
if existing_snapshot_store is None:
_set_snapshot_store(protocol_runner, snapshot_store)
existing_snapshot_store = snapshot_store
if existing_snapshot_store is not None and snapshot_scope_resolver is None:
raise ValueError(
"snapshot_scope_resolver is required when snapshot_store is configured. "
"AG-UI Thread ids identify threads but do not authorize snapshot access; "
"provide a resolver that returns an explicit Snapshot Scope."
)
def _validate_keepalive_seconds(keepalive_seconds: float | None) -> None:
if keepalive_seconds is not None and not keepalive_seconds > 0:
raise ValueError("keepalive_seconds must be positive or None.")
def add_agent_framework_fastapi_endpoint(
app: FastAPI,
agent: SupportsAgentRun | AgentFrameworkAgent | Workflow | AgentFrameworkWorkflow,
path: str = "/",
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
allow_origins: list[str] | None = None,
default_state: dict[str, Any] | None = None,
tags: list[str] | None = None,
dependencies: Sequence[Depends] | None = None,
snapshot_store: AGUIThreadSnapshotStore | None = None,
snapshot_scope_resolver: SnapshotScopeResolver | None = None,
keepalive_seconds: float | None = 15,
) -> None:
"""Add an AG-UI endpoint to a FastAPI app.
Args:
app: The FastAPI application
agent: The agent to expose (can be raw SupportsAgentRun or wrapped)
path: The endpoint path
state_schema: Optional state schema for shared state management; accepts dict or Pydantic model/class
predict_state_config: Optional predictive state update configuration.
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
allow_origins: CORS origins (not yet implemented)
default_state: Optional initial state to seed when the client does not provide state keys
tags: OpenAPI tags for endpoint categorization (defaults to ["AG-UI"])
dependencies: Optional FastAPI dependencies for authentication/authorization.
These dependencies run before the endpoint handler. Use this to add
authentication checks, rate limiting, or other middleware-like behavior.
Example: `dependencies=[Depends(verify_api_key)]`
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence is opt-in and requires an
explicit Snapshot Scope resolver.
snapshot_scope_resolver: Optional resolver for the application-defined Snapshot Scope. Required whenever
a snapshot store is configured because an AG-UI Thread id is not an authorization boundary.
keepalive_seconds: Endpoint SSE keepalive interval in seconds. Defaults to 15. Positive values emit fixed
SSE comments while the stream is open. None disables keepalive and preserves the non-keepalive response
path. Keepalive comments are transport traffic and do not change AG-UI events.
"""
_validate_keepalive_seconds(keepalive_seconds)
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,
snapshot_store=snapshot_store,
)
else:
raise TypeError("agent must be SupportsAgentRun, Workflow, AgentFrameworkAgent, or AgentFrameworkWorkflow.")
_configure_snapshot_persistence(
protocol_runner,
snapshot_store=snapshot_store,
snapshot_scope_resolver=snapshot_scope_resolver,
)
@app.post(path, tags=tags or ["AG-UI"], dependencies=dependencies, response_model=None) # type: ignore[arg-type]
async def agent_endpoint(request_body: AGUIRequest) -> Response:
"""Handle AG-UI agent requests.
Note: Function is accessed via FastAPI's decorator registration,
despite appearing unused to static analysis.
"""
try:
input_data = request_body.model_dump(exclude_none=True)
snapshot_persistence_active = False
if snapshot_scope_resolver is not None:
snapshot_scope = snapshot_scope_resolver(request_body)
if isawaitable(snapshot_scope):
snapshot_scope = await snapshot_scope
input_data[_APPROVAL_SCOPE_INPUT_KEY] = snapshot_scope
if _get_snapshot_store(protocol_runner) is not None:
input_data[_SNAPSHOT_SCOPE_INPUT_KEY] = snapshot_scope
snapshot_persistence_active = True
if default_state:
if snapshot_persistence_active:
# Defer default application to the runner so defaults only fill keys
# missing from both the stored snapshot state and the request state.
input_data[_DEFAULT_STATE_INPUT_KEY] = copy.deepcopy(default_state)
else:
state = input_data.setdefault("state", {})
for key, value in default_state.items():
if key not in state:
state[key] = copy.deepcopy(value)
logger.debug(
f"[{path}] Received request - Run ID: {input_data.get('run_id', 'no-run-id')}, "
f"Thread ID: {input_data.get('thread_id', 'no-thread-id')}, "
f"Messages: {len(input_data.get('messages', []))}"
)
logger.info(f"Received request at {path}: {input_data.get('run_id', 'no-run-id')}")
keepalive_enabled = keepalive_seconds is not None
def prepare_frame(encoded: str) -> str | bytes:
if keepalive_enabled:
return encoded.encode("utf-8")
return encoded
async def event_generator() -> AsyncGenerator[str | bytes]:
encoder = EventEncoder()
event_count = 0
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}")
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 prepare_frame(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 prepare_frame(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__,
)
try:
yield prepare_frame(encoder.encode(run_error))
except Exception:
logger.exception("[%s] Failed to encode RUN_ERROR event", path)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
if keepalive_seconds is not None:
from sse_starlette.event import ServerSentEvent
from sse_starlette.sse import EventSourceResponse
return EventSourceResponse(
event_generator(),
ping=cast(int, keepalive_seconds),
ping_message_factory=lambda: ServerSentEvent(comment=_KEEPALIVE_COMMENT),
headers=headers,
media_type="text/event-stream",
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers=headers,
)
except Exception as e:
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="An internal error has occurred.") from e
@@ -0,0 +1,279 @@
# Copyright (c) Microsoft. All rights reserved.
"""Event converter for AG-UI protocol events to Agent Framework types."""
from __future__ import annotations
import logging
from typing import Any
from agent_framework import (
ChatResponseUpdate,
Content,
)
logger = logging.getLogger(__name__)
class AGUIEventConverter:
"""Converter for AG-UI events to Agent Framework types.
Handles conversion of AG-UI protocol events to ChatResponseUpdate objects
while maintaining state, aggregating content, and tracking metadata.
"""
def __init__(self) -> None:
"""Initialize the converter with fresh state."""
self.current_message_id: str | None = None
self.current_tool_call_id: str | None = None
self.current_tool_name: str | None = None
self.accumulated_tool_args: str = ""
self.thread_id: str | None = None
self.run_id: str | None = None
@staticmethod
def _get_tool_call_id(event: dict[str, Any]) -> str | None:
"""Return the tool call ID from either AG-UI field spelling."""
tool_call_id = event.get("toolCallId")
if tool_call_id is None:
tool_call_id = event.get("tool_call_id")
if tool_call_id is None:
return None
return str(tool_call_id)
def convert_event(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Convert a single AG-UI event to ChatResponseUpdate.
Args:
event: AG-UI event dictionary
Returns:
ChatResponseUpdate if event produces content, None otherwise
Examples:
RUN_STARTED event:
.. code-block:: python
converter = AGUIEventConverter()
event = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"}
update = converter.convert_event(event)
assert update.additional_properties["thread_id"] == "t1"
TEXT_MESSAGE_CONTENT event:
.. code-block:: python
event = {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hello"}
update = converter.convert_event(event)
assert update.contents[0].text == "Hello"
"""
raw_event_type = str(event.get("type", ""))
event_type = raw_event_type.upper()
if event_type == "RUN_STARTED":
return self._handle_run_started(event)
elif event_type == "TEXT_MESSAGE_START":
return self._handle_text_message_start(event)
elif event_type == "TEXT_MESSAGE_CONTENT":
return self._handle_text_message_content(event)
elif event_type == "TEXT_MESSAGE_END":
return self._handle_text_message_end(event)
elif event_type == "TOOL_CALL_START":
return self._handle_tool_call_start(event)
elif event_type == "TOOL_CALL_ARGS":
return self._handle_tool_call_args(event)
elif event_type == "TOOL_CALL_END":
return self._handle_tool_call_end(event)
elif event_type == "TOOL_CALL_RESULT":
return self._handle_tool_call_result(event)
elif event_type == "RUN_FINISHED":
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
def _handle_run_started(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_STARTED event."""
self.thread_id = event.get("threadId")
self.run_id = event.get("runId")
return ChatResponseUpdate(
role="assistant",
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_text_message_start(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_START event."""
self.current_message_id = event.get("messageId")
return ChatResponseUpdate(
role="assistant",
message_id=self.current_message_id,
contents=[],
)
def _handle_text_message_content(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TEXT_MESSAGE_CONTENT event."""
message_id = event.get("messageId")
delta = event.get("delta", "")
if message_id != self.current_message_id:
self.current_message_id = message_id
return ChatResponseUpdate(
role="assistant",
message_id=self.current_message_id,
contents=[Content.from_text(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_END event."""
return None
def _handle_tool_call_start(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_START event."""
self.current_tool_call_id = self._get_tool_call_id(event)
self.current_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name")
self.accumulated_tool_args = ""
return ChatResponseUpdate(
role="assistant",
contents=[
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
)
],
)
def _handle_tool_call_args(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_ARGS event."""
event_tool_call_id = self._get_tool_call_id(event)
if event_tool_call_id is not None:
if self.current_tool_call_id and event_tool_call_id != self.current_tool_call_id:
logger.warning(
"Ignoring TOOL_CALL_ARGS for toolCallId=%s while current toolCallId=%s",
event_tool_call_id,
self.current_tool_call_id,
)
return None
if not self.current_tool_call_id:
self.current_tool_call_id = event_tool_call_id
delta = event.get("delta", "")
self.accumulated_tool_args += delta
return ChatResponseUpdate(
role="assistant",
contents=[
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
)
],
)
def _handle_tool_call_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_END event."""
event_tool_call_id = self._get_tool_call_id(event)
if (
self.current_tool_call_id is None
or event_tool_call_id is None
or event_tool_call_id == self.current_tool_call_id
):
self.current_tool_call_id = None
self.current_tool_name = None
self.accumulated_tool_args = ""
return None
def _handle_tool_call_result(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_RESULT event."""
tool_call_id = event.get("toolCallId", "")
result = event.get("result") if event.get("result") is not None else event.get("content")
return ChatResponseUpdate(
role="tool",
contents=[
Content.from_function_result(
call_id=tool_call_id,
result=result,
)
],
)
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 "outcome" in event:
outcome = event.get("outcome")
additional_properties["outcome"] = outcome
if not isinstance(outcome, dict):
logger.warning(
"RUN_FINISHED outcome should be an object; got %s. Preserving raw outcome.",
type(outcome).__name__,
)
elif outcome.get("type") == "interrupt":
interrupts = outcome.get("interrupts")
if isinstance(interrupts, list):
additional_properties["interrupts"] = interrupts
if "result" in event:
additional_properties["result"] = event.get("result")
return ChatResponseUpdate(
role="assistant",
finish_reason="stop",
contents=[],
additional_properties=additional_properties,
)
def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_ERROR event."""
error_message = event.get("message", "Unknown error")
return ChatResponseUpdate(
role="assistant",
finish_reason="content_filter",
contents=[
Content.from_error(
message=error_message,
error_code="RUN_ERROR",
)
],
additional_properties={
"thread_id": self.thread_id,
"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,
},
},
)
@@ -0,0 +1,265 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP service for AG-UI protocol communication."""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterable, Mapping, Sequence
from typing import Any, cast
import httpx
from ag_ui.core import Interrupt, ResumeEntry
logger = logging.getLogger(__name__)
def _json_safe_protocol_value(value: Any) -> Any:
"""Convert protocol values to JSON-compatible data using AG-UI aliases."""
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return _json_safe_protocol_value(model_dump(by_alias=True, exclude_none=True))
if isinstance(value, Mapping):
return {key: _json_safe_protocol_value(item) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_json_safe_protocol_value(item) for item in value]
return value
def _serialize_available_interrupts(available_interrupts: Sequence[Any] | None) -> list[dict[str, Any]] | None:
"""Serialize typed or compatible interrupt inputs to canonical AG-UI JSON."""
if available_interrupts is None:
return None
serialized: list[dict[str, Any]] = []
for interrupt in available_interrupts:
if isinstance(interrupt, Mapping) and "reason" not in interrupt:
interrupt = dict(interrupt)
interrupt_type = interrupt.pop("type", None)
if interrupt_type == "request_info" or interrupt_type is None:
interrupt["reason"] = "input_required"
elif isinstance(interrupt_type, str):
interrupt["reason"] = interrupt_type
serialized.append(
cast(dict[str, Any], Interrupt.model_validate(interrupt).model_dump(by_alias=True, exclude_none=True))
)
return serialized
def _serialize_resume_entry(entry: Any) -> dict[str, Any]:
"""Serialize one typed or legacy resume entry to canonical AG-UI JSON."""
model_dump = getattr(entry, "model_dump", None)
if callable(model_dump):
entry = model_dump(by_alias=True, exclude_none=True)
if not isinstance(entry, Mapping):
raise ValueError("Each resume entry must be an object.")
entry_dict = cast(Mapping[str, Any], entry)
interrupt_id = (
entry_dict.get("interruptId")
or entry_dict.get("interrupt_id")
or entry_dict.get("id")
or entry_dict.get("toolCallId")
)
if not interrupt_id:
raise ValueError("Each resume entry must include interruptId.")
status = entry_dict.get("status") or "resolved"
payload = (
entry_dict.get("payload")
if "payload" in entry_dict
else entry_dict.get("value")
if "value" in entry_dict
else entry_dict.get("response")
if "response" in entry_dict
else {
key: value
for key, value in entry_dict.items()
if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"}
}
)
serialized: dict[str, Any] = {"interruptId": str(interrupt_id), "status": str(status)}
if status != "cancelled" or payload:
serialized["payload"] = _json_safe_protocol_value(payload)
return cast(dict[str, Any], ResumeEntry.model_validate(serialized).model_dump(by_alias=True, exclude_none=True))
def _serialize_resume(resume: Any) -> Any: # noqa: ANN401
"""Serialize typed or compatible resume inputs to canonical AG-UI JSON."""
if resume is None:
return None
if isinstance(resume, Sequence) and not isinstance(resume, (str, bytes, bytearray)):
return [_serialize_resume_entry(entry) for entry in resume]
if isinstance(resume, Mapping):
resume_dict = cast(Mapping[str, Any], resume)
if isinstance(resume_dict.get("interrupts"), Sequence) and not isinstance(
resume_dict.get("interrupts"), (str, bytes, bytearray)
):
return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupts"])]
if isinstance(resume_dict.get("interrupt"), Sequence) and not isinstance(
resume_dict.get("interrupt"), (str, bytes, bytearray)
):
return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupt"])]
if any(key in resume_dict for key in ("interruptId", "interrupt_id", "id", "toolCallId")):
return [_serialize_resume_entry(resume_dict)]
return _json_safe_protocol_value(resume)
class AGUIHttpService:
"""HTTP service for AG-UI protocol communication.
Handles HTTP POST requests and Server-Sent Events (SSE) stream parsing
for the AG-UI protocol.
Examples:
Basic usage:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_123",
run_id="run_456",
messages=[{"role": "user", "content": "Hello"}]
):
print(event["type"])
With context manager:
.. code-block:: python
async with AGUIHttpService("http://localhost:8888/") as service:
async for event in service.post_run(...):
print(event)
"""
def __init__(
self,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
"""Initialize the HTTP service.
Args:
endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx AsyncClient. If None, creates a new one.
timeout: Request timeout in seconds (default: 60.0)
"""
self.endpoint = endpoint.rstrip("/")
self._owns_client = http_client is None
self.http_client = http_client or httpx.AsyncClient(timeout=timeout)
async def post_run(
self,
thread_id: str,
run_id: str,
messages: list[dict[str, Any]],
state: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
available_interrupts: Sequence[Any] | None = None,
resume: Any = None,
) -> AsyncIterable[dict[str, Any]]:
"""Post a run request and stream AG-UI events.
Args:
thread_id: Thread identifier for conversation continuity
run_id: Unique run identifier
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
Raises:
httpx.HTTPStatusError: If the HTTP request fails
ValueError: If SSE parsing encounters invalid data
Examples:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_abc",
run_id="run_123",
messages=[{"role": "user", "content": "Hello"}],
state={"user_context": {"name": "Alice"}}
):
if event["type"] == "TEXT_MESSAGE_CONTENT":
print(event["delta"])
"""
# Build request payload
request_data: dict[str, Any] = {
"thread_id": thread_id,
"run_id": run_id,
"messages": messages,
}
if state is not None:
request_data["state"] = state
if tools is not None:
request_data["tools"] = tools
serialized_available_interrupts = _serialize_available_interrupts(available_interrupts)
if serialized_available_interrupts is not None:
request_data["availableInterrupts"] = serialized_available_interrupts
serialized_resume = _serialize_resume(resume)
if serialized_resume is not None:
request_data["resume"] = serialized_resume
logger.debug(
f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, "
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
async with self.http_client.stream(
"POST",
self.endpoint,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP request failed: {e.response.status_code} - {e.response.text}")
raise
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
logger.debug(f"Received event: {event.get('type', 'UNKNOWN')}")
yield event
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {data}. Error: {e}")
# Continue processing other events instead of failing
continue
async def close(self) -> None:
"""Close the HTTP client if owned by this service.
Only closes the client if it was created by this service instance.
If an external client was provided, it remains the caller's
responsibility to close it.
"""
if self._owns_client and self.http_client:
await self.http_client.aclose()
async def __aenter__(self) -> AGUIHttpService:
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager and clean up resources."""
await self.close()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,247 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helper functions for orchestration logic.
This module retains utilities that may be useful for testing or extensions.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent_framework import (
Content,
Message,
)
from .._utils import get_role_value
logger = logging.getLogger(__name__)
def pending_tool_call_ids(messages: list[Message]) -> set[str]:
"""Get IDs of tool calls without corresponding results.
Args:
messages: List of messages to scan
Returns:
Set of pending tool call IDs
"""
pending_ids: set[str] = set()
resolved_ids: set[str] = set()
for msg in messages:
for content in msg.contents:
if content.type == "function_call" and content.call_id:
pending_ids.add(str(content.call_id))
elif content.type == "function_result" and content.call_id:
resolved_ids.add(str(content.call_id))
return pending_ids - resolved_ids
def is_state_context_message(message: Message) -> bool:
"""Check if a message is a state context system message.
Args:
message: Message to check
Returns:
True if this is a state context message
"""
if get_role_value(message) != "system":
return False
for content in message.contents:
if content.type == "text" and content.text.startswith("Current state of the application:"): # type: ignore[union-attr]
return True
return False
def ensure_tool_call_entry(
tool_call_id: str,
tool_calls_by_id: dict[str, dict[str, Any]],
pending_tool_calls: list[dict[str, Any]],
) -> dict[str, Any]:
"""Get or create a tool call entry in the tracking dicts.
Args:
tool_call_id: The tool call ID
tool_calls_by_id: Dict mapping IDs to tool call entries
pending_tool_calls: List of pending tool calls
Returns:
The tool call entry dict
"""
entry = tool_calls_by_id.get(tool_call_id)
if entry is None:
entry = {
"id": tool_call_id,
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
tool_calls_by_id[tool_call_id] = entry
pending_tool_calls.append(entry)
return entry
def tool_name_for_call_id(
tool_calls_by_id: dict[str, dict[str, Any]],
tool_call_id: str,
) -> str | None:
"""Get the tool name for a given call ID.
Args:
tool_calls_by_id: Dict mapping IDs to tool call entries
tool_call_id: The tool call ID to look up
Returns:
Tool name or None if not found
"""
entry = tool_calls_by_id.get(tool_call_id)
if not entry:
return None
function = entry.get("function")
if not isinstance(function, dict):
return None
name = function.get("name")
return str(name) if name else None
def schema_has_steps(schema: Any) -> bool:
"""Check if a schema has a steps array property.
Args:
schema: JSON schema to check
Returns:
True if schema has steps array
"""
if not isinstance(schema, dict):
return False
properties = schema.get("properties")
if not isinstance(properties, dict):
return False
steps_schema = properties.get("steps")
if not isinstance(steps_schema, dict):
return False
return steps_schema.get("type") == "array"
def select_approval_tool_name(client_tools: list[Any] | None) -> str | None:
"""Select appropriate approval tool from client tools.
Args:
client_tools: List of client tool definitions
Returns:
Name of approval tool, or None if not found
"""
if not client_tools:
return None
for tool in client_tools:
tool_name = getattr(tool, "name", None)
if not tool_name:
continue
params_fn = getattr(tool, "parameters", None)
if not callable(params_fn):
continue
schema = params_fn()
if schema_has_steps(schema):
return str(tool_name)
return None
def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Build metadata dict with truncated string values for Azure compatibility.
Azure has a 512 character limit per metadata value.
Args:
thread_metadata: Raw metadata dict
Returns:
Metadata with string values truncated to 512 chars
"""
if not thread_metadata:
return {}
safe_metadata: dict[str, Any] = {}
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
safe_metadata[key] = value_str
return safe_metadata
def latest_approval_response(messages: list[Message]) -> Content | None:
"""Get the latest approval response from messages.
Args:
messages: Messages to search
Returns:
Latest approval response or None
"""
if not messages:
return None
last_message = messages[-1]
for content in last_message.contents:
if content.type == "function_approval_response":
return content
return None
def approval_steps(approval: Content) -> list[Any]:
"""Extract steps from an approval response.
Args:
approval: Approval response content
Returns:
List of steps, or empty list if none
"""
state_args = approval.additional_properties.get("ag_ui_state_args", None)
if isinstance(state_args, dict):
steps = state_args.get("steps")
if isinstance(steps, list):
return steps
if approval.function_call:
parsed_args = approval.function_call.parse_arguments()
if isinstance(parsed_args, dict):
steps = parsed_args.get("steps")
if isinstance(steps, list):
return steps
return []
def is_step_based_approval(
approval: Content,
predict_state_config: dict[str, dict[str, str]] | None,
) -> bool:
"""Check if an approval is step-based.
Args:
approval: Approval response to check
predict_state_config: Predictive state configuration
Returns:
True if this is a step-based approval
"""
steps = approval_steps(approval)
if steps:
return True
if not approval.function_call:
return False
if not predict_state_config:
return False
tool_name = approval.function_call.name
for config in predict_state_config.values():
if config.get("tool") == tool_name and config.get("tool_argument") == "steps":
return True
return False
@@ -0,0 +1,232 @@
# Copyright (c) Microsoft. All rights reserved.
"""Predictive state handling utilities."""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from ag_ui.core import StateDeltaEvent
from .._utils import safe_json_parse
logger = logging.getLogger(__name__)
class PredictiveStateHandler:
"""Handles predictive state updates from streaming tool calls."""
def __init__(
self,
predict_state_config: dict[str, dict[str, str]] | None = None,
current_state: dict[str, Any] | None = None,
) -> None:
"""Initialize the handler.
Args:
predict_state_config: Configuration mapping state keys to tool/argument pairs
current_state: Reference to current state dict
"""
self.predict_state_config = predict_state_config or {}
self.current_state = current_state or {}
self.streaming_tool_args: str = ""
self.last_emitted_state: dict[str, Any] = {}
self.state_delta_count: int = 0
self.pending_state_updates: dict[str, Any] = {}
def reset_streaming(self) -> None:
"""Reset streaming state for a new tool call."""
self.streaming_tool_args = ""
self.state_delta_count = 0
def extract_state_value(
self,
tool_name: str,
args: dict[str, Any] | str | None,
) -> tuple[str, Any] | None:
"""Extract state value from tool arguments based on config.
Args:
tool_name: Name of the tool being called
args: Tool arguments (dict or JSON string)
Returns:
Tuple of (state_key, state_value) or None if no match
"""
if not self.predict_state_config:
return None
parsed_args = safe_json_parse(args) if isinstance(args, str) else args
if not parsed_args:
return None
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
return (state_key, parsed_args)
if tool_arg_name in parsed_args:
return (state_key, parsed_args[tool_arg_name])
return None
def is_predictive_tool(self, tool_name: str | None) -> bool:
"""Check if a tool is configured for predictive state.
Args:
tool_name: Name of the tool to check
Returns:
True if tool is in predictive state config
"""
if not tool_name or not self.predict_state_config:
return False
for config in self.predict_state_config.values():
if config["tool"] == tool_name:
return True
return False
def emit_streaming_deltas(
self,
tool_name: str | None,
argument_chunk: str,
) -> list[StateDeltaEvent]:
"""Process streaming argument chunk and emit state deltas.
Args:
tool_name: Name of the current tool
argument_chunk: New chunk of JSON arguments
Returns:
List of state delta events to emit
"""
events: list[StateDeltaEvent] = []
if not tool_name or not self.predict_state_config:
return events
self.streaming_tool_args += argument_chunk
logger.debug(
"Predictive state: accumulated %s chars for tool '%s'",
len(self.streaming_tool_args),
tool_name,
)
# Try to parse complete JSON first
parsed_args = None
try:
parsed_args = json.loads(self.streaming_tool_args)
except json.JSONDecodeError:
# Fall back to regex matching for partial JSON
events.extend(self._emit_partial_deltas(tool_name))
if parsed_args:
events.extend(self._emit_complete_deltas(tool_name, parsed_args))
return events
def _emit_partial_deltas(self, tool_name: str) -> list[StateDeltaEvent]:
"""Emit deltas from partial JSON using regex matching.
Args:
tool_name: Name of the current tool
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
match = re.search(pattern, self.streaming_tool_args)
if match:
partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value:
event = self._create_delta_event(state_key, partial_value)
events.append(event)
self.last_emitted_state[state_key] = partial_value
self.pending_state_updates[state_key] = partial_value
return events
def _emit_complete_deltas(
self,
tool_name: str,
parsed_args: dict[str, Any],
) -> list[StateDeltaEvent]:
"""Emit deltas from complete parsed JSON.
Args:
tool_name: Name of the current tool
parsed_args: Fully parsed arguments dict
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
continue
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
event = self._create_delta_event(state_key, state_value)
events.append(event)
self.last_emitted_state[state_key] = state_value
self.pending_state_updates[state_key] = state_value
return events
def _create_delta_event(self, state_key: str, value: Any) -> StateDeltaEvent:
"""Create a state delta event with logging.
Args:
state_key: The state key being updated
value: The new value
Returns:
StateDeltaEvent instance
"""
self.state_delta_count += 1
if self.state_delta_count % 10 == 1:
logger.info(
"StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s",
self.state_delta_count,
state_key,
state_key,
len(str(value)),
)
elif self.state_delta_count % 100 == 0:
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
return StateDeltaEvent(
delta=[
{
"op": "replace",
"path": f"/{state_key}",
"value": value,
}
],
)
def apply_pending_updates(self) -> None:
"""Apply pending updates to current state and clear them."""
for key, value in self.pending_state_updates.items():
self.current_state[key] = value
self.pending_state_updates.clear()
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool handling helpers."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from agent_framework import BaseChatClient
from agent_framework._tools import _append_unique_tools # pyright: ignore[reportPrivateUsage]
if TYPE_CHECKING:
from agent_framework import SupportsAgentRun
logger = logging.getLogger(__name__)
def _collect_mcp_tool_functions(mcp_tools: list[Any]) -> list[Any]:
"""Extract functions from connected MCP tools.
Args:
mcp_tools: List of MCP tool instances.
Returns:
Functions from connected MCP tools.
"""
functions: list[Any] = []
for mcp_tool in mcp_tools:
if getattr(mcp_tool, "is_connected", False) and hasattr(mcp_tool, "functions"):
functions.extend(mcp_tool.functions)
return functions
def collect_server_tools(agent: SupportsAgentRun) -> list[Any]:
"""Collect server tools from an agent.
This includes both regular tools from default_options and MCP tools.
MCP tools are stored separately for lifecycle management but their
functions need to be included for tool execution during approval flows.
Args:
agent: Agent instance to collect tools from. Works with Agent
or any agent with default_options and optional mcp_tools attributes.
Returns:
List of tools including both regular tools and connected MCP tool functions.
"""
# Get tools from default_options
default_options = getattr(agent, "default_options", None)
if default_options is None:
return []
tools_from_agent = default_options.get("tools") if isinstance(default_options, dict) else None
server_tools = list(tools_from_agent) if tools_from_agent else []
# Include functions from connected MCP tools (only available on Agent)
mcp_tools = getattr(agent, "mcp_tools", None)
if mcp_tools:
_append_unique_tools(
server_tools,
_collect_mcp_tool_functions(mcp_tools),
duplicate_error_message="Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool.",
)
logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools")
for tool in server_tools:
tool_name = getattr(tool, "name", "unknown")
approval_mode = getattr(tool, "approval_mode", None)
logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}")
return server_tools
def register_additional_client_tools(agent: SupportsAgentRun, client_tools: list[Any] | None) -> None:
"""Register client tools as additional declaration-only tools to avoid server execution.
Args:
agent: Agent instance to register tools on. Works with Agent
or any agent with a client attribute.
client_tools: List of client tools to register.
"""
if not client_tools:
return
client = getattr(agent, "client", None)
if client is None:
return
if isinstance(client, BaseChatClient) and client.function_invocation_configuration is not None: # type: ignore[attr-defined]
client.function_invocation_configuration["additional_tools"] = client_tools # type: ignore[attr-defined]
logger.debug(f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)")
def _has_approval_tools(tools: list[Any]) -> bool:
"""Check if any tools require approval."""
return any(getattr(tool, "approval_mode", None) == "always_require" for tool in tools)
def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list[Any] | None:
"""Combine server and client tools without overriding server metadata.
IMPORTANT: When server tools have approval_mode="always_require", we MUST return
them so they get passed to the streaming response handler. Otherwise, the approval
check in _try_execute_function_calls won't find the tool and won't trigger approval.
"""
if not client_tools:
# Even without client tools, we must pass server tools if any require approval
if server_tools and _has_approval_tools(server_tools):
logger.info(
f"[TOOLS] No client tools but server has approval tools - "
f"passing {len(server_tools)} server tools for approval mode"
)
return server_tools
logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)")
return None
combined_tools = _append_unique_tools(
list(server_tools),
client_tools,
duplicate_error_message="Tool names must be unique.",
)
logger.info(
f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools "
f"({len(server_tools)} server + {len(client_tools)} client)"
)
return combined_tools
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Thread Snapshot storage primitives."""
from __future__ import annotations
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, runtime_checkable
if TYPE_CHECKING:
from ._types import AGUIRequest
SnapshotScope: TypeAlias = str
"""Application-defined scope for authorizing access to AG-UI Thread Snapshots."""
AGUIThreadID: TypeAlias = str
"""AG-UI Thread identifier within a Snapshot Scope."""
SnapshotScopeResolver: TypeAlias = Callable[["AGUIRequest"], str | Awaitable[str]]
"""Callable that resolves the Snapshot Scope for an AG-UI endpoint request."""
_SnapshotKey: TypeAlias = tuple[SnapshotScope, AGUIThreadID]
DEFAULT_MAX_THREAD_SNAPSHOTS = 1_000
_SNAPSHOT_SCOPE_INPUT_KEY = "__ag_ui_snapshot_scope"
_DEFAULT_STATE_INPUT_KEY = "__ag_ui_default_state"
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class AGUIThreadSnapshot:
"""Replayable AG-UI Thread state.
AG-UI Thread Snapshots intentionally contain only data that can be replayed
to a UI: message snapshots, optional Shared State, and optional interruption
state. They do not include raw events, request metadata, auth claims,
diagnostics, traces, or provider responses.
Attributes:
messages: Replayable AG-UI message snapshots.
state: Optional AG-UI Shared State snapshot.
interrupt: Optional interruption state from ``RUN_FINISHED.outcome.interrupts``.
"""
messages: list[dict[str, Any]] = field(default_factory=list)
state: dict[str, Any] | None = None
interrupt: list[dict[str, Any]] | None = None
@runtime_checkable
class AGUIThreadSnapshotStore(Protocol):
"""Async store for latest AG-UI Thread Snapshots keyed by scope and thread id."""
async def save(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
snapshot: AGUIThreadSnapshot,
) -> None:
"""Save the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope. This is part of the
storage key and must represent the app's authorization boundary.
thread_id: AG-UI Thread id within the scope.
snapshot: Snapshot to save.
"""
...
async def get(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> AGUIThreadSnapshot | None:
"""Get the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope.
thread_id: AG-UI Thread id within the scope.
Returns:
The latest snapshot, or ``None`` when no snapshot exists for the key.
"""
...
async def delete(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> bool:
"""Delete the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope.
thread_id: AG-UI Thread id within the scope.
Returns:
``True`` when a snapshot was deleted, otherwise ``False``.
"""
...
async def clear(self, *, scope: SnapshotScope | None = None) -> None:
"""Clear saved snapshots.
Args:
scope: Optional Snapshot Scope to clear. When omitted, all in-memory
snapshots are cleared.
"""
...
class InMemoryAGUIThreadSnapshotStore:
"""Bounded memory-only latest snapshot store for local development, demos, and tests.
This store keeps at most one snapshot per ``(scope, thread_id)`` key. It is
process-local and not durable production storage.
"""
def __init__(self, *, max_snapshots: int = DEFAULT_MAX_THREAD_SNAPSHOTS) -> None:
"""Initialize the in-memory snapshot store.
Keyword Args:
max_snapshots: Maximum number of scoped thread snapshots to retain.
Raises:
ValueError: If ``max_snapshots`` is less than 1.
"""
if max_snapshots < 1:
raise ValueError("max_snapshots must be greater than 0.")
self._max_snapshots = max_snapshots
self._snapshots: dict[_SnapshotKey, AGUIThreadSnapshot] = {}
async def save(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
snapshot: AGUIThreadSnapshot,
) -> None:
"""Save the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
key = self._key(scope=scope, thread_id=thread_id)
if key in self._snapshots:
del self._snapshots[key]
self._snapshots[key] = copy.deepcopy(snapshot)
self._evict_oldest()
async def get(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> AGUIThreadSnapshot | None:
"""Get the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
snapshot = self._snapshots.get(self._key(scope=scope, thread_id=thread_id))
return copy.deepcopy(snapshot) if snapshot is not None else None
async def delete(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> bool:
"""Delete the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
key = self._key(scope=scope, thread_id=thread_id)
if key not in self._snapshots:
return False
del self._snapshots[key]
return True
async def clear(self, *, scope: SnapshotScope | None = None) -> None:
"""Clear saved snapshots, optionally limited to one Snapshot Scope."""
if scope is None:
self._snapshots.clear()
return
normalized_scope = self._normalize_key_part(scope, "scope")
for key in list(self._snapshots):
if key[0] == normalized_scope:
del self._snapshots[key]
@classmethod
def _key(cls, *, scope: SnapshotScope, thread_id: AGUIThreadID) -> _SnapshotKey:
return (
cls._normalize_key_part(scope, "scope"),
cls._normalize_key_part(thread_id, "thread_id"),
)
@staticmethod
def _normalize_key_part(value: str, name: str) -> str:
if not isinstance(value, str):
raise TypeError(f"{name} must be a string.")
if not value:
raise ValueError(f"{name} must be a non-empty string.")
return value
def _evict_oldest(self) -> None:
while len(self._snapshots) > self._max_snapshots:
del self._snapshots[next(iter(self._snapshots))]
async def _clear_thread_snapshot_interrupt(
*,
snapshot_store: AGUIThreadSnapshotStore,
scope: SnapshotScope,
thread_id: AGUIThreadID,
interrupt_ids: set[str] | None = None,
) -> None:
"""Clear completed interruption state from the latest replayable thread snapshot."""
try:
snapshot = await snapshot_store.get(scope=scope, thread_id=thread_id)
if snapshot is None or snapshot.interrupt is None:
return
if interrupt_ids is None:
snapshot.interrupt = None
else:
remaining_interrupts = [
interrupt
for interrupt in snapshot.interrupt
if str(interrupt.get("id") or interrupt.get("interruptId")) not in interrupt_ids
]
snapshot.interrupt = remaining_interrupts or None
await snapshot_store.save(scope=scope, thread_id=thread_id, snapshot=snapshot)
except Exception:
logger.exception(
"Failed to clear AG-UI Thread Snapshot interrupt for scope=%s thread_id=%s; keeping previous snapshot.",
scope,
thread_id,
)
@@ -0,0 +1,137 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deterministic tool-driven AG-UI state updates and display payloads.
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
deterministic state update or a per-call tool result display payload by
returning :func:`state_update`. Unlike ``predict_state_config`` — which emits
``StateDeltaEvent``s optimistically from LLM-predicted tool call arguments —
``state_update`` runs *after* the tool executes, so AG-UI state and display
content always reflect the tool's actual return value.
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
motivating discussion.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from agent_framework import Content
from ._utils import make_json_safe
__all__ = ["TOOL_RESULT_DISPLAY_KEY", "TOOL_RESULT_STATE_KEY", "state_update"]
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
state snapshot from a tool return value through to the AG-UI emitter."""
TOOL_RESULT_DISPLAY_KEY = "__ag_ui_tool_result_display__"
"""Reserved ``Content.additional_properties`` key used to carry UI-only tool result display content from a tool return value through to the AG-UI emitter."""
_UNSET = object()
def _serialize_tool_result(value: Any) -> str: # noqa: ANN401
return value if isinstance(value, str) else json.dumps(make_json_safe(value))
def state_update(
text: str = "",
*,
state: Mapping[str, Any] | None = None,
tool_result: Any = _UNSET, # noqa: ANN401
) -> Content:
"""Build a tool return value that updates AG-UI shared state or display content.
Return the result of this helper from an agent tool to push a state update
or UI-only display payload to AG-UI clients using the actual tool output,
rather than LLM-predicted tool arguments.
When the AG-UI endpoint emits the tool result, it will:
* Forward ``text`` to the LLM as the normal ``function_result`` content.
* Use ``tool_result`` as the ``ToolCallResultEvent.content`` payload shown
to AG-UI clients, falling back to ``text`` when no display payload is set.
* Merge ``state`` into ``FlowState.current_state``.
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
event so frontends observe the updated state deterministically. If
predictive state is enabled, a predictive snapshot may be emitted first.
Example:
.. code-block:: python
from agent_framework import Content, tool
from agent_framework_ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await _fetch_weather(city)
return state_update(
text=f"Weather in {city}: {data['temp']}°C {data['conditions']}",
state={"weather": {"city": city, **data}},
)
Example:
.. code-block:: python
from agent_framework import Content, tool
from agent_framework_ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await _fetch_weather(city)
return state_update(
text=f"{city}: {data['temp']}°C and {data['conditions']}",
tool_result={
"component": "weather-card",
"city": city,
"temperature": data["temp"],
"conditions": data["conditions"],
"humidity": data["humidity"],
},
state={"weather": {"city": city, **data}},
)
Args:
text: Text passed back to the LLM as the ``function_result`` content.
Defaults to an empty string for tools whose only output is a state
update.
state: A mapping merged into the AG-UI shared state via JSON-compatible
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
tool_result: JSON-safe payload emitted to AG-UI clients as
``ToolCallResultEvent.content`` for frontend rendering. The LLM
still receives ``text``. If ``text`` is empty, the serialized
display payload is also used as the LLM-bound text fallback.
Returns:
A ``Content`` object with ``type="text"``. The state payload rides in
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY`
(``"__ag_ui_tool_result_state__"``), and the display payload rides
under :data:`TOOL_RESULT_DISPLAY_KEY`
(``"__ag_ui_tool_result_display__"``). Both reserved keys are extracted
by the AG-UI emitter.
Raises:
TypeError: If ``state`` is not a ``Mapping``.
"""
if state is not None and not isinstance(state, Mapping):
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
additional_properties: dict[str, Any] = {}
if state is not None:
additional_properties[TOOL_RESULT_STATE_KEY] = dict(state)
if tool_result is not _UNSET:
display_content = _serialize_tool_result(tool_result)
additional_properties[TOOL_RESULT_DISPLAY_KEY] = display_content
if not text:
text = display_content
return Content.from_text(
text,
additional_properties=additional_properties,
)
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Type definitions for AG-UI integration."""
from __future__ import annotations
import sys
from typing import Annotated, Any, Generic
from ag_ui.core import Interrupt, ResumeEntry
from agent_framework import ChatOptions
from pydantic import AliasChoices, BaseModel, BeforeValidator, Field
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
AGUIChatOptionsT = TypeVar("AGUIChatOptionsT", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type]
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
def _coerce_legacy_resume_entry(value: Any) -> Any: # noqa: ANN401
if not isinstance(value, dict):
return value
interrupt_id = value.get("interruptId") or value.get("interrupt_id") or value.get("id") or value.get("toolCallId")
if not interrupt_id:
return value
if "payload" in value:
payload = value.get("payload")
elif "value" in value:
payload = value.get("value")
elif "response" in value:
payload = value.get("response")
else:
payload = {
key: item
for key, item in value.items()
if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"}
}
entry: dict[str, Any] = {"interruptId": str(interrupt_id), "status": value.get("status", "resolved")}
if payload is not None:
entry["payload"] = payload
return entry
def _coerce_legacy_resume(value: Any) -> Any: # noqa: ANN401
if value is None:
return value
if isinstance(value, dict):
if "interrupts" in value:
value = value["interrupts"]
elif "interrupt" in value:
value = value["interrupt"]
elif any(key in value for key in ("interruptId", "interrupt_id", "id", "toolCallId")):
value = [value]
else:
return value
if not isinstance(value, list):
return value
return [_coerce_legacy_resume_entry(entry) for entry in value]
class PredictStateConfig(TypedDict):
"""Configuration for predictive state updates."""
state_key: str
tool: str
tool_argument: str | None
class RunMetadata(TypedDict):
"""Metadata for agent run."""
run_id: str
thread_id: str
predict_state: list[PredictStateConfig] | None
class AgentState(TypedDict):
"""Base state for AG-UI agents."""
messages: list[Any] | None
class AGUIRequest(BaseModel):
"""Request model for AG-UI endpoints."""
messages: list[dict[str, Any]] = Field(
...,
description="AG-UI format messages array",
)
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(
None,
description="Optional shared state for agentic generative UI",
)
tools: list[dict[str, Any]] | None = Field(
None,
description="Client-side tools to advertise to the LLM",
)
context: list[dict[str, Any]] | None = Field(
None,
description="List of context objects provided to the agent",
)
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[Interrupt] | None = Field(
None,
validation_alias=AliasChoices("availableInterrupts", "available_interrupts"),
description="Canonical AG-UI interrupts that can be resumed by the server",
)
resume: Annotated[list[ResumeEntry], BeforeValidator(_coerce_legacy_resume)] | None = Field(
None,
description="Resume payload for continuing interrupted runs",
)
# region AG-UI Chat Options TypedDict
class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False):
"""AG-UI protocol-specific chat options dict.
Extends base ChatOptions for the AG-UI (Agent-UI) protocol.
AG-UI is a streaming protocol for connecting AI agents to user interfaces.
Options are forwarded to the remote AG-UI server.
See: https://github.com/ag-ui/ag-ui-protocol
Keys:
# Inherited from ChatOptions (forwarded to remote server):
model: The model identifier (forwarded as-is to server).
temperature: Sampling temperature.
top_p: Nucleus sampling parameter.
max_tokens: Maximum tokens to generate.
stop: Stop sequences.
tools: List of tools - sent to server so LLM knows about client tools.
Server executes its own tools; client tools execute locally via
function invocation middleware.
tool_choice: How the model should use tools.
metadata: Metadata dict containing thread_id for conversation continuity.
# Options with limited support (depends on remote server):
frequency_penalty: Forwarded if remote server supports it.
presence_penalty: Forwarded if remote server supports it.
seed: Forwarded if remote server supports it.
response_format: Forwarded if remote server supports it.
logit_bias: Forwarded if remote server supports it.
user: Forwarded if remote server supports it.
# Options not typically used in AG-UI:
store: Not applicable for AG-UI protocol.
allow_multiple_tool_calls: Handled by underlying server.
# AG-UI-specific options:
forward_props: Additional properties to forward to the AG-UI server.
Useful for passing custom parameters to specific server implementations.
context: Shared context/state to send to the server.
Note:
AG-UI is a protocol bridge - actual option support depends on the
remote server implementation. The client sends all options to the
server, which decides how to handle them.
Thread ID management:
- Pass ``thread_id`` in ``metadata`` to maintain conversation continuity
- If not provided, a new thread ID is auto-generated
"""
# AG-UI-specific options
forward_props: dict[str, Any]
"""Additional properties to forward to the AG-UI server."""
context: dict[str, Any]
"""Shared context/state to send to the server."""
available_interrupts: list[Interrupt]
"""Canonical AG-UI interrupt descriptors available for resumption."""
resume: list[ResumeEntry]
"""Canonical AG-UI resume entries to continue a paused run."""
# ChatOptions fields not applicable for AG-UI
store: None # type: ignore[misc]
"""Not applicable for AG-UI protocol."""
AGUI_OPTION_TRANSLATIONS: dict[str, str] = {}
"""Maps ChatOptions keys to AG-UI parameter names (protocol uses standard names)."""
# endregion
@@ -0,0 +1,292 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility functions for AG-UI integration."""
from __future__ import annotations
import copy
import json
import uuid
from collections.abc import Callable, MutableMapping, Sequence
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any
from agent_framework import AgentResponseUpdate, ChatResponseUpdate, FunctionTool
# Role mapping constants
AGUI_TO_FRAMEWORK_ROLE: dict[str, str] = {
"user": "user",
"assistant": "assistant",
"system": "system",
}
FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = {
"user": "user",
"assistant": "assistant",
"system": "system",
}
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"}
def generate_event_id() -> str:
"""Generate a unique event ID."""
return str(uuid.uuid4())
def safe_json_parse(value: Any) -> dict[str, Any] | None:
"""Safely parse a value as JSON dict.
Args:
value: String or dict to parse
Returns:
Parsed dict or None if parsing fails
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return None
def canonical_function_arguments(function_call: Any) -> str | None:
"""Return a stable representation of function-call arguments."""
if function_call is None:
return None
try:
parsed_arguments = function_call.parse_arguments()
except Exception:
parsed_arguments = getattr(function_call, "arguments", None)
if parsed_arguments is None:
parsed_arguments = {}
return json.dumps(make_json_safe(parsed_arguments), sort_keys=True, separators=(",", ":"))
def get_role_value(message: Any) -> str:
"""Extract role string from a message object.
Handles both enum roles (with .value) and string roles.
Args:
message: Message object with role attribute
Returns:
Role as lowercase string, or empty string if not found
"""
role = getattr(message, "role", None)
if role is None:
return ""
if hasattr(role, "value"):
return str(role.value)
return str(role)
def normalize_agui_role(raw_role: Any) -> str:
"""Normalize an AG-UI role to a standard role string.
Args:
raw_role: Raw role value from AG-UI message
Returns:
Normalized role string (user, assistant, system, tool, or reasoning)
"""
if not isinstance(raw_role, str):
return "user"
role = raw_role.lower()
if role == "developer":
return "system"
if role in ALLOWED_AGUI_ROLES:
return role
return "user"
def extract_state_from_tool_args(
args: dict[str, Any] | None,
tool_arg_name: str,
) -> Any:
"""Extract state value from tool arguments based on config.
Args:
args: Parsed tool arguments dict
tool_arg_name: Name of the argument to extract, or "*" for entire args
Returns:
Extracted state value, or None if not found
"""
if not args:
return None
if tool_arg_name == "*":
return args
return args.get(tool_arg_name)
def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Merge state updates.
Args:
current: Current state dictionary
update: Update to apply
Returns:
Merged state
"""
result = copy.deepcopy(current)
result.update(update)
return result
def make_json_safe(obj: Any) -> Any: # noqa: ANN401
"""Make an object JSON serializable.
Args:
obj: Object to make JSON safe
Returns:
JSON-serializable version of the object
"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if is_dataclass(obj):
# asdict may return nested non-dataclass objects, so recursively make them safe
return make_json_safe(asdict(obj)) # type: ignore[arg-type]
if hasattr(obj, "model_dump"):
return make_json_safe(obj.model_dump())
if hasattr(obj, "to_dict"):
return make_json_safe(obj.to_dict())
if hasattr(obj, "dict"):
return make_json_safe(obj.dict())
if hasattr(obj, "__dict__"):
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
if isinstance(obj, (list, tuple)):
return [make_json_safe(item) for item in obj] # type: ignore[misc]
if isinstance(obj, dict):
return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
return str(obj)
def convert_agui_tools_to_agent_framework(
agui_tools: list[dict[str, Any]] | None,
) -> list[FunctionTool] | None:
"""Convert AG-UI tool definitions to Agent Framework FunctionTool declarations.
Creates declaration-only FunctionTool instances (no executable implementation).
These are used to tell the LLM about available tools. The actual execution
happens on the client side via function invocation mixin.
CRITICAL: These tools MUST have func=None so that declaration_only returns True.
This prevents the server from trying to execute client-side tools.
Args:
agui_tools: List of AG-UI tool definitions with name, description, parameters
Returns:
List of FunctionTool declarations, or None if no tools provided
"""
if not agui_tools:
return None
result: list[FunctionTool] = []
for tool_def in agui_tools:
# Create declaration-only FunctionTool (func=None means no implementation)
# When func=None, the declaration_only property returns True,
# which tells the function invocation mixin to return the function call
# without executing it (so it can be sent back to the client)
func: FunctionTool = FunctionTool(
name=tool_def.get("name", ""),
description=tool_def.get("description", ""),
func=None, # CRITICAL: Makes declaration_only=True
input_model=tool_def.get("parameters", {}),
)
result.append(func)
return result
def convert_tools_to_agui_format(
tools: (
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[dict[str, Any]] | None:
"""Convert tools to AG-UI format.
This sends only the metadata (name, description, JSON schema) to the server.
The actual executable implementation stays on the client side.
The function invocation mixin handles client-side execution when
the server requests a function.
Args:
tools: Tools to convert (single tool or sequence of tools)
Returns:
List of tool specifications in AG-UI format, or None if no tools provided
"""
if not tools:
return None
# Normalize to list
if not isinstance(tools, list):
tool_list: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item]
else:
tool_list = tools # type: ignore[assignment]
results: list[dict[str, Any]] = []
for tool_item in tool_list:
if isinstance(tool_item, dict):
# Already in dict format, pass through
results.append(tool_item) # type: ignore[arg-type]
elif isinstance(tool_item, FunctionTool):
# Convert FunctionTool to AG-UI tool format
results.append(
{
"name": tool_item.name,
"description": tool_item.description,
"parameters": tool_item.parameters(),
}
)
elif callable(tool_item):
# Convert callable to FunctionTool first, then to AG-UI format
from agent_framework import tool
ai_func = tool(tool_item)
results.append(
{
"name": ai_func.name,
"description": ai_func.description,
"parameters": ai_func.parameters(),
}
)
# Note: dict-based hosted tools (CodeInterpreter, WebSearch, etc.) are passed through
# as-is in the first branch. Non-FunctionTool, non-dict items are skipped.
return results if results else None
def get_conversation_id_from_update(update: AgentResponseUpdate) -> str | None:
"""Extract conversation ID from AgentResponseUpdate metadata.
Args:
update: AgentRunResponseUpdate instance
Returns:
Conversation ID if present, else None
"""
if isinstance(update.raw_representation, ChatResponseUpdate):
return update.raw_representation.conversation_id
return None
@@ -0,0 +1,404 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow wrapper for AG-UI protocol compatibility."""
from __future__ import annotations
import copy
import logging
import uuid
from collections.abc import AsyncGenerator, Callable
from typing import Any, cast
from ag_ui.core import (
BaseEvent,
MessagesSnapshotEvent,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import Workflow
from ._message_adapters import agui_messages_to_snapshot_format
from ._run_common import (
_build_run_finished_event,
_extract_resume_payload,
_normalize_resume_interrupts,
_reconstruct_messages_from_thread_snapshot,
)
from ._snapshots import (
_DEFAULT_STATE_INPUT_KEY,
_SNAPSHOT_SCOPE_INPUT_KEY,
AGUIThreadSnapshot,
AGUIThreadSnapshotStore,
_clear_thread_snapshot_interrupt,
)
from ._utils import generate_event_id, make_json_safe
from ._workflow_run import run_workflow_stream
logger = logging.getLogger(__name__)
WorkflowFactory = Callable[[str], Workflow]
def _cancelled_resume_interrupt_ids(resume_payload: Any) -> set[str]:
"""Return cancelled interrupt ids from a resume payload."""
return {
str(interrupt["id"])
for interrupt in _normalize_resume_interrupts(resume_payload)
if interrupt.get("status") == "cancelled"
}
def _event_messages_to_snapshot_dicts(messages: list[Any]) -> list[dict[str, Any]]:
"""Convert AG-UI message event models to plain snapshot dictionaries."""
safe_messages = make_json_safe(messages)
if not isinstance(safe_messages, list):
return []
return [cast(dict[str, Any], message) for message in safe_messages if isinstance(message, dict)]
class _WorkflowSnapshotBuilder:
"""Capture replayable workflow protocol output without retaining raw events."""
def __init__(self, raw_messages: list[dict[str, Any]]) -> None:
self._synthesized_messages = agui_messages_to_snapshot_format(raw_messages)
self._emitted_messages: list[dict[str, Any]] | None = None
self._open_text_message: dict[str, Any] | None = None
self._tool_call_message: dict[str, Any] | None = None
self._tool_calls_by_id: dict[str, dict[str, Any]] = {}
self.state: dict[str, Any] | None = None
self.interrupt: list[dict[str, Any]] | None = None
def observe(self, event: BaseEvent) -> None:
"""Fold one replayable AG-UI event into the latest snapshot state."""
if isinstance(event, StateSnapshotEvent):
state = make_json_safe(event.snapshot)
if isinstance(state, dict):
self.state = cast(dict[str, Any], state)
return
if isinstance(event, MessagesSnapshotEvent):
self._emitted_messages = _event_messages_to_snapshot_dicts(list(event.messages))
return
if isinstance(event, RunFinishedEvent):
outcome = getattr(event, "outcome", None)
interrupt = (
make_json_safe(getattr(outcome, "interrupts", None))
if getattr(outcome, "type", None) == "interrupt"
else None
)
if isinstance(interrupt, list):
self.interrupt = [cast(dict[str, Any], item) for item in interrupt if isinstance(item, dict)]
return
if self._emitted_messages is not None:
return
if isinstance(event, TextMessageStartEvent):
self._observe_text_start(event)
elif isinstance(event, TextMessageContentEvent):
self._observe_text_content(event)
elif isinstance(event, TextMessageEndEvent):
self._observe_text_end(event)
elif isinstance(event, ToolCallStartEvent):
self._observe_tool_call_start(event)
elif isinstance(event, ToolCallArgsEvent):
self._observe_tool_call_args(event)
elif isinstance(event, ToolCallResultEvent):
self._observe_tool_call_result(event)
def build(self) -> AGUIThreadSnapshot:
"""Return the replayable thread snapshot."""
self._flush_open_text_message()
messages = self._emitted_messages if self._emitted_messages is not None else self._synthesized_messages
return AGUIThreadSnapshot(messages=messages, state=self.state, interrupt=self.interrupt)
def _observe_text_start(self, event: TextMessageStartEvent) -> None:
if self._open_text_message is not None and self._open_text_message.get("id") != event.message_id:
self._flush_open_text_message()
self._open_text_message = {"id": event.message_id, "role": event.role, "content": ""}
def _observe_text_content(self, event: TextMessageContentEvent) -> None:
if self._open_text_message is None or self._open_text_message.get("id") != event.message_id:
self._open_text_message = {"id": event.message_id, "role": "assistant", "content": ""}
self._open_text_message["content"] = f"{self._open_text_message.get('content', '')}{event.delta}"
def _observe_text_end(self, event: TextMessageEndEvent) -> None:
if self._open_text_message is None or self._open_text_message.get("id") != event.message_id:
return
self._flush_open_text_message()
def _observe_tool_call_start(self, event: ToolCallStartEvent) -> None:
parent_message_id = event.parent_message_id
if (
self._open_text_message is not None
and parent_message_id is not None
and self._open_text_message.get("id") == parent_message_id
and self._open_text_message.get("content")
):
self._open_text_message["id"] = generate_event_id()
self._flush_open_text_message()
if self._tool_call_message is None or (
parent_message_id is not None and self._tool_call_message.get("id") != parent_message_id
):
self._tool_call_message = {
"id": parent_message_id or generate_event_id(),
"role": "assistant",
"tool_calls": [],
}
self._synthesized_messages.append(self._tool_call_message)
tool_call = {
"id": event.tool_call_id,
"type": "function",
"function": {"name": event.tool_call_name, "arguments": ""},
}
cast(list[dict[str, Any]], self._tool_call_message["tool_calls"]).append(tool_call)
self._tool_calls_by_id[event.tool_call_id] = tool_call
def _observe_tool_call_args(self, event: ToolCallArgsEvent) -> None:
tool_call = self._tool_calls_by_id.get(event.tool_call_id)
if tool_call is None:
return
function_payload = cast(dict[str, Any], tool_call["function"])
function_payload["arguments"] = f"{function_payload.get('arguments', '')}{event.delta}"
def _observe_tool_call_result(self, event: ToolCallResultEvent) -> None:
self._synthesized_messages.append(
{
"id": event.message_id,
"role": "tool",
"toolCallId": event.tool_call_id,
"content": event.content,
}
)
# A result closes the current tool-call group; later tool calls start a new
# assistant message so replayed transcripts keep results adjacent to their
# tool_calls message, which provider APIs require.
self._tool_call_message = None
def _flush_open_text_message(self) -> None:
if self._open_text_message is None:
return
if self._open_text_message.get("content"):
self._synthesized_messages.append(self._open_text_message)
# Text between tool calls closes the current tool-call group as well.
self._tool_call_message = None
self._open_text_message = None
async def _hydrate_workflow_thread_snapshot(
*,
snapshot_store: AGUIThreadSnapshotStore,
scope: str,
thread_id: str,
run_id: str,
) -> AsyncGenerator[BaseEvent]:
"""Replay the latest stored workflow AG-UI Thread Snapshot without invoking the workflow."""
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
snapshot = await snapshot_store.get(scope=scope, thread_id=thread_id)
if snapshot is None:
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
return
if snapshot.state is not None:
yield StateSnapshotEvent(snapshot=snapshot.state)
if snapshot.messages:
yield MessagesSnapshotEvent(messages=snapshot.messages) # type: ignore[arg-type]
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=snapshot.interrupt)
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,
snapshot_store: AGUIThreadSnapshotStore | None = None,
) -> None:
"""Initialize the AG-UI workflow wrapper.
Args:
workflow: Optional workflow instance to expose.
workflow_factory: Optional factory for thread-scoped workflow instances.
name: Optional workflow name.
description: Optional workflow description.
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
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
# Cache keyed by (snapshot_scope, thread_id): the Snapshot Scope is the
# authorization boundary, so the same thread id under different scopes
# must never share an in-memory workflow instance.
self._workflow_by_thread: dict[tuple[str | None, 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", "")
self.snapshot_store = snapshot_store
@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, snapshot_scope: str | None = None) -> 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=.")
cache_key = (snapshot_scope, thread_id)
workflow = self._workflow_by_thread.get(cache_key)
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[cache_key] = workflow
return workflow
def clear_thread_workflow(self, thread_id: str, snapshot_scope: str | None = None) -> None:
"""Drop cached workflow instances for a thread, optionally limited to one Snapshot Scope."""
if snapshot_scope is not None:
self._workflow_by_thread.pop((snapshot_scope, thread_id), None)
return
for key in [key for key in self._workflow_by_thread if key[1] == thread_id]:
del self._workflow_by_thread[key]
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)
run_id = str(input_data.get("run_id") or input_data.get("runId") or uuid.uuid4())
snapshot_scope = cast(str | None, input_data.get(_SNAPSHOT_SCOPE_INPUT_KEY))
raw_messages = list(cast(list[dict[str, Any]], input_data.get("messages", []) or []))
resume_payload = _extract_resume_payload(input_data)
snapshot_store = self.snapshot_store
if snapshot_store is not None and snapshot_scope is not None and not raw_messages and resume_payload is None:
async for event in _hydrate_workflow_thread_snapshot(
snapshot_store=snapshot_store,
scope=snapshot_scope,
thread_id=thread_id,
run_id=run_id,
):
yield event
return
# Load the stored snapshot for follow-up turns so the workflow runs with the
# full persisted thread history instead of just the latest request messages.
stored_snapshot: AGUIThreadSnapshot | None = None
if snapshot_store is not None and snapshot_scope is not None:
stored_snapshot = await snapshot_store.get(scope=snapshot_scope, thread_id=thread_id)
if stored_snapshot is not None and resume_payload is None:
raw_messages = _reconstruct_messages_from_thread_snapshot(
stored_messages=stored_snapshot.messages,
incoming_messages=raw_messages,
stored_interrupt=stored_snapshot.interrupt,
)
input_data["messages"] = raw_messages
# Merge stored state with request overrides, then fill endpoint-deferred
# defaults only for keys missing from both.
request_state = input_data.get("state")
deferred_default_state = cast(dict[str, Any] | None, input_data.get(_DEFAULT_STATE_INPUT_KEY))
effective_state: dict[str, Any] = {}
if stored_snapshot is not None and stored_snapshot.state is not None:
effective_state.update(stored_snapshot.state)
if isinstance(request_state, dict):
effective_state.update(cast(dict[str, Any], request_state))
if deferred_default_state:
for key, value in deferred_default_state.items():
if key not in effective_state:
effective_state[key] = copy.deepcopy(value)
if effective_state:
input_data["state"] = effective_state
workflow = self._resolve_workflow(thread_id, snapshot_scope)
builder_seed_messages = raw_messages
if resume_payload is not None and stored_snapshot is not None:
# Resume requests carry only the synthesized interrupt response, so seed
# the builder with stored history to avoid persisting a truncated thread.
builder_seed_messages = [
copy.deepcopy(message) for message in stored_snapshot.messages
] + builder_seed_messages
snapshot_builder = (
_WorkflowSnapshotBuilder(builder_seed_messages)
if snapshot_store is not None and snapshot_scope is not None
else None
)
if snapshot_builder is not None and effective_state:
# Seed builder state so a run that emits no StateSnapshotEvent still
# persists the latest known Shared State instead of dropping it.
state_snapshot = make_json_safe(effective_state)
if isinstance(state_snapshot, dict):
snapshot_builder.state = cast(dict[str, Any], state_snapshot)
run_error_emitted = False
async for event in run_workflow_stream(input_data, workflow):
if snapshot_builder is not None:
snapshot_builder.observe(event)
if isinstance(event, RunErrorEvent):
run_error_emitted = True
if (
getattr(event, "code", None) == "WORKFLOW_RESUME_CANCELLED"
and snapshot_store is not None
and snapshot_scope is not None
):
await _clear_thread_snapshot_interrupt(
snapshot_store=snapshot_store,
scope=snapshot_scope,
thread_id=thread_id,
interrupt_ids=_cancelled_resume_interrupt_ids(resume_payload),
)
yield event
if (
snapshot_builder is not None
and not run_error_emitted
and snapshot_store is not None
and snapshot_scope is not None
):
try:
await snapshot_store.save(
scope=snapshot_scope,
thread_id=thread_id,
snapshot=snapshot_builder.build(),
)
except Exception:
# RUN_FINISHED has already been yielded; a store failure must not
# surface as a second terminal RUN_ERROR event. The previous
# snapshot stays available for hydration.
logger.exception(
"Failed to save AG-UI Thread Snapshot for scope=%s thread_id=%s; keeping previous snapshot.",
snapshot_scope,
thread_id,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
# Marker file for PEP 561