mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: AG-UI protocol support (#1826)
* Add AG-UI integration * Fix tests. PR feedback * Cleanup * PR Feedback * Improve README and getting started experience * Fix links
This commit is contained in:
committed by
GitHub
Unverified
parent
0c862e97a6
commit
35a8565495
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AG-UI protocol integration for Agent Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent import AgentFrameworkAgent
|
||||
from ._confirmation_strategies import (
|
||||
ConfirmationStrategy,
|
||||
DefaultConfirmationStrategy,
|
||||
DocumentWriterConfirmationStrategy,
|
||||
RecipeConfirmationStrategy,
|
||||
TaskPlannerConfirmationStrategy,
|
||||
)
|
||||
from ._endpoint import add_agent_framework_fastapi_endpoint
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"AgentFrameworkAgent",
|
||||
"add_agent_framework_fastapi_endpoint",
|
||||
"ConfirmationStrategy",
|
||||
"DefaultConfirmationStrategy",
|
||||
"TaskPlannerConfirmationStrategy",
|
||||
"RecipeConfirmationStrategy",
|
||||
"DocumentWriterConfirmationStrategy",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AgentFrameworkAgent wrapper for AG-UI protocol - Clean Architecture."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
from ._confirmation_strategies import ConfirmationStrategy, DefaultConfirmationStrategy
|
||||
from ._orchestrators import (
|
||||
DefaultOrchestrator,
|
||||
ExecutionContext,
|
||||
HumanInTheLoopOrchestrator,
|
||||
Orchestrator,
|
||||
)
|
||||
|
||||
|
||||
class AgentConfig:
|
||||
"""Configuration for agent wrapper."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_schema: dict[str, Any] | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
):
|
||||
"""Initialize agent configuration.
|
||||
|
||||
Args:
|
||||
state_schema: Optional state schema for state management
|
||||
predict_state_config: Configuration for predictive state updates
|
||||
require_confirmation: Whether predictive updates require confirmation
|
||||
"""
|
||||
self.state_schema = state_schema or {}
|
||||
self.predict_state_config = predict_state_config or {}
|
||||
self.require_confirmation = require_confirmation
|
||||
|
||||
|
||||
class AgentFrameworkAgent:
|
||||
"""Wraps Agent Framework agents for AG-UI protocol compatibility.
|
||||
|
||||
Translates between Agent Framework's AgentProtocol and AG-UI's event-based
|
||||
protocol. Uses orchestrators to handle different execution flows (standard
|
||||
execution, human-in-the-loop, etc.). Orchestrators are checked in order;
|
||||
the first matching orchestrator handles the request.
|
||||
|
||||
Supports predictive state updates for agentic generative UI, with optional
|
||||
confirmation requirements configurable per use case.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
state_schema: dict[str, Any] | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
orchestrators: list[Orchestrator] | None = None,
|
||||
confirmation_strategy: ConfirmationStrategy | 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
|
||||
predict_state_config: Configuration for predictive state updates.
|
||||
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
|
||||
require_confirmation: Whether predictive updates require confirmation.
|
||||
Set to False for agentic generative UI that updates automatically.
|
||||
orchestrators: Custom orchestrators (auto-configured if None).
|
||||
Orchestrators are checked in order; first match handles the request.
|
||||
confirmation_strategy: Strategy for generating confirmation messages.
|
||||
Defaults to DefaultConfirmationStrategy if None.
|
||||
"""
|
||||
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,
|
||||
require_confirmation=require_confirmation,
|
||||
)
|
||||
|
||||
# Configure orchestrators
|
||||
if orchestrators is None:
|
||||
self.orchestrators = self._default_orchestrators()
|
||||
else:
|
||||
self.orchestrators = orchestrators
|
||||
|
||||
# Configure confirmation strategy
|
||||
if confirmation_strategy is None:
|
||||
self.confirmation_strategy: ConfirmationStrategy = DefaultConfirmationStrategy()
|
||||
else:
|
||||
self.confirmation_strategy = confirmation_strategy
|
||||
|
||||
def _default_orchestrators(self) -> list[Orchestrator]:
|
||||
"""Create default orchestrator chain.
|
||||
|
||||
Returns:
|
||||
List of orchestrators in priority order. First matching orchestrator
|
||||
handles the request, so order matters.
|
||||
"""
|
||||
return [
|
||||
HumanInTheLoopOrchestrator(), # Handle tool approval responses
|
||||
# Add more specialized orchestrators here as needed
|
||||
DefaultOrchestrator(), # Fallback: standard agent execution
|
||||
]
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
input_data: dict[str, Any],
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Run the agent and yield AG-UI events.
|
||||
|
||||
This is the ONLY public method - much simpler than the original 376-line
|
||||
implementation. All orchestration logic has been extracted into dedicated
|
||||
Orchestrator classes.
|
||||
|
||||
The method creates an ExecutionContext with all needed data, then finds
|
||||
the first orchestrator that can handle the request and delegates to it.
|
||||
|
||||
Args:
|
||||
input_data: The AG-UI run input containing messages, state, etc.
|
||||
|
||||
Yields:
|
||||
AG-UI events
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no orchestrator matches (should never happen if
|
||||
DefaultOrchestrator is last in the chain)
|
||||
"""
|
||||
# Create execution context with all needed data
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=self.agent,
|
||||
config=self.config,
|
||||
confirmation_strategy=self.confirmation_strategy,
|
||||
)
|
||||
|
||||
# Find matching orchestrator and execute
|
||||
for orchestrator in self.orchestrators:
|
||||
if orchestrator.can_handle(context):
|
||||
async for event in orchestrator.run(context):
|
||||
yield event
|
||||
return
|
||||
|
||||
# Should never reach here if DefaultOrchestrator is last
|
||||
raise RuntimeError("No orchestrator matched - check configuration")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentFrameworkAgent",
|
||||
"AgentConfig",
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Confirmation strategies for human-in-the-loop approval flows.
|
||||
|
||||
Each agent can provide a custom confirmation strategy to generate domain-specific
|
||||
messages when users approve or reject changes/actions.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfirmationStrategy(ABC):
|
||||
"""Strategy for generating confirmation messages during human-in-the-loop flows."""
|
||||
|
||||
@abstractmethod
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user approves function execution.
|
||||
|
||||
Args:
|
||||
steps: List of approved steps with 'description', 'status', etc.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user rejects function execution.
|
||||
|
||||
Args:
|
||||
steps: List of rejected steps
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate message when user confirms predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate message when user rejects predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DefaultConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Generic confirmation messages suitable for most agents.
|
||||
|
||||
This preserves the original behavior from v1.
|
||||
"""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic approval message with step list."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
|
||||
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll steps completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic rejection message."""
|
||||
return "No problem! What would you like me to change about the plan?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate generic state confirmation message."""
|
||||
return "Changes confirmed and applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate generic state rejection message."""
|
||||
return "No problem! What would you like me to change?"
|
||||
|
||||
|
||||
class TaskPlannerConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for task planning agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
|
||||
message_parts = ["Executing your requested tasks:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll tasks completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific rejection message."""
|
||||
return "No problem! Let me revise the plan. What would you like me to change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
return "Tasks confirmed and ready to execute!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
return "No problem! How should I adjust the task list?"
|
||||
|
||||
|
||||
class RecipeConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for recipe agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
|
||||
message_parts = ["Updating your recipe:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nRecipe updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific rejection message."""
|
||||
return "No problem! What ingredients or steps should I change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate recipe-specific state confirmation message."""
|
||||
return "Recipe changes applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate recipe-specific state rejection message."""
|
||||
return "No problem! What would you like me to adjust in the recipe?"
|
||||
|
||||
|
||||
class DocumentWriterConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for document writing agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
|
||||
message_parts = ["Applying your edits:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nDocument updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific rejection message."""
|
||||
return "No problem! Which changes should I keep or modify?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate document-specific state confirmation message."""
|
||||
return "Document edits applied!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate document-specific state rejection message."""
|
||||
return "No problem! What should I change about the document?"
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""FastAPI endpoint creation for AG-UI agents."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agent_framework import AgentProtocol
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ._agent import AgentFrameworkAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def add_agent_framework_fastapi_endpoint(
|
||||
app: FastAPI,
|
||||
agent: AgentProtocol | AgentFrameworkAgent,
|
||||
path: str = "/",
|
||||
state_schema: dict[str, Any] | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
allow_origins: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Add an AG-UI endpoint to a FastAPI app.
|
||||
|
||||
Args:
|
||||
app: The FastAPI application
|
||||
agent: The agent to expose (can be raw AgentProtocol or wrapped)
|
||||
path: The endpoint path
|
||||
state_schema: Optional state schema for shared state management
|
||||
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)
|
||||
"""
|
||||
if isinstance(agent, AgentProtocol):
|
||||
wrapped_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
state_schema=state_schema,
|
||||
predict_state_config=predict_state_config,
|
||||
)
|
||||
else:
|
||||
wrapped_agent = agent
|
||||
|
||||
@app.post(path)
|
||||
async def agent_endpoint(request: Request): # type: ignore[misc]
|
||||
"""Handle AG-UI agent requests.
|
||||
|
||||
Note: Function is accessed via FastAPI's decorator registration,
|
||||
despite appearing unused to static analysis.
|
||||
"""
|
||||
try:
|
||||
input_data = await request.json()
|
||||
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')}")
|
||||
|
||||
async def event_generator():
|
||||
encoder = EventEncoder()
|
||||
event_count = 0
|
||||
async for event in wrapped_agent.run_agent(input_data):
|
||||
event_count += 1
|
||||
logger.debug(f"[{path}] Event {event_count}: {type(event).__name__}")
|
||||
|
||||
# Log event payload for debugging
|
||||
if hasattr(event, "model_dump"):
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
logger.debug(f"[{path}] Event payload: {event_data}")
|
||||
|
||||
encoded = encoder.encode(event)
|
||||
logger.debug(
|
||||
f"[{path}] Encoded as: {encoded[:200]}..."
|
||||
if len(encoded) > 200
|
||||
else f"[{path}] Encoded as: {encoded}"
|
||||
)
|
||||
yield encoded
|
||||
logger.info(f"[{path}] Completed streaming {event_count} events")
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
@@ -0,0 +1,675 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Event bridge for converting Agent Framework events to AG-UI protocol."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
EventType,
|
||||
MessagesSnapshotEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateDeltaEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
AgentRunResponseUpdate,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from ._utils import generate_event_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentFrameworkEventBridge:
|
||||
"""Converts Agent Framework responses to AG-UI events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str,
|
||||
thread_id: str,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
current_state: dict[str, Any] | None = None,
|
||||
skip_text_content: bool = False,
|
||||
input_messages: list[Any] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the event bridge.
|
||||
|
||||
Args:
|
||||
run_id: The run identifier.
|
||||
thread_id: The thread identifier.
|
||||
predict_state_config: Configuration for predictive state updates.
|
||||
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
|
||||
current_state: Reference to the current state dict for tracking updates.
|
||||
skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs).
|
||||
input_messages: The input messages from the conversation history.
|
||||
require_confirmation: Whether predictive state updates require user confirmation.
|
||||
"""
|
||||
self.run_id = run_id
|
||||
self.thread_id = thread_id
|
||||
self.current_message_id: str | None = None
|
||||
self.current_tool_call_id: str | None = None
|
||||
self.current_tool_call_name: str | None = None # Track the tool name across streaming chunks
|
||||
self.predict_state_config = predict_state_config or {}
|
||||
self.current_state = current_state or {}
|
||||
self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls
|
||||
self.skip_text_content = skip_text_content
|
||||
self.require_confirmation = require_confirmation
|
||||
|
||||
# For predictive state updates: accumulate streaming arguments
|
||||
self.streaming_tool_args: str = "" # Accumulated JSON string
|
||||
self.last_emitted_state: dict[str, Any] = {} # Track last emitted state to avoid duplicates
|
||||
self.state_delta_count: int = 0 # Counter for sampling log output
|
||||
self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes
|
||||
self.suppressed_summary: str = "" # Store LLM summary to show after confirmation
|
||||
|
||||
# For MessagesSnapshotEvent: track tool calls and results
|
||||
self.input_messages = input_messages or []
|
||||
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
|
||||
self.tool_results: list[dict[str, Any]] = [] # Track tool results
|
||||
|
||||
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
|
||||
"""
|
||||
Convert an AgentRunResponseUpdate to AG-UI events.
|
||||
|
||||
Args:
|
||||
update: The agent run update to convert.
|
||||
|
||||
Returns:
|
||||
List of AG-UI events.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
for content in update.contents:
|
||||
if isinstance(content, TextContent):
|
||||
# Skip text content if using structured outputs (it's just the JSON)
|
||||
if self.skip_text_content:
|
||||
continue
|
||||
|
||||
# Skip text content if we're about to emit confirm_changes
|
||||
# The summary should only appear after user confirms
|
||||
if self.should_stop_after_confirm:
|
||||
logger.debug(" >>> Skipping text content - waiting for confirm_changes response")
|
||||
# Save the summary text to show after confirmation
|
||||
self.suppressed_summary += content.text
|
||||
continue
|
||||
|
||||
if not self.current_message_id:
|
||||
self.current_message_id = generate_event_id()
|
||||
start_event = TextMessageStartEvent(
|
||||
message_id=self.current_message_id,
|
||||
role="assistant",
|
||||
)
|
||||
events.append(start_event)
|
||||
|
||||
event = TextMessageContentEvent(
|
||||
message_id=self.current_message_id,
|
||||
delta=content.text,
|
||||
)
|
||||
events.append(event)
|
||||
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
# Log tool calls for debugging
|
||||
if content.name:
|
||||
logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})")
|
||||
|
||||
if not content.name and not content.call_id and not self.current_tool_call_name:
|
||||
args_preview = str(content.arguments)[:50] if content.arguments else "None"
|
||||
logger.warning(f"FunctionCallContent missing name and call_id. Args: {args_preview}")
|
||||
|
||||
# Get or use existing tool call ID - all chunks of same tool call share the same call_id
|
||||
# Important: the first chunk might have name but no call_id yet
|
||||
if content.call_id:
|
||||
tool_call_id = content.call_id
|
||||
elif self.current_tool_call_id:
|
||||
tool_call_id = self.current_tool_call_id
|
||||
else:
|
||||
# Generate a new ID for this tool call
|
||||
tool_call_id = (
|
||||
generate_event_id()
|
||||
) # Handle streaming tool calls - name comes in first chunk, arguments in subsequent chunks
|
||||
if content.name:
|
||||
# This is a new tool call or the first chunk with the name
|
||||
self.current_tool_call_id = tool_call_id
|
||||
self.current_tool_call_name = content.name
|
||||
|
||||
tool_start_event = ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=content.name,
|
||||
parent_message_id=self.current_message_id,
|
||||
)
|
||||
logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
|
||||
events.append(tool_start_event)
|
||||
|
||||
# Track tool call for MessagesSnapshotEvent
|
||||
# Initialize a new tool call entry
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": content.name,
|
||||
"arguments": "", # Will accumulate as we get argument chunks
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Subsequent chunk without name - update our tracked ID if needed
|
||||
if tool_call_id:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
|
||||
# Emit arguments if present
|
||||
if content.arguments:
|
||||
# content.arguments is already a JSON string from the LLM for streaming calls
|
||||
# For non-streaming it could be a dict, so we need to handle both
|
||||
if isinstance(content.arguments, str):
|
||||
delta_str = content.arguments
|
||||
else:
|
||||
# If it's a dict, convert to JSON
|
||||
delta_str = json.dumps(content.arguments)
|
||||
|
||||
logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
|
||||
args_event = ToolCallArgsEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
delta=delta_str,
|
||||
)
|
||||
events.append(args_event)
|
||||
|
||||
# Accumulate arguments for MessagesSnapshotEvent
|
||||
if self.pending_tool_calls:
|
||||
# Find the matching tool call and append the delta
|
||||
for tool_call in self.pending_tool_calls:
|
||||
if tool_call["id"] == tool_call_id:
|
||||
tool_call["function"]["arguments"] += delta_str
|
||||
break
|
||||
|
||||
# Predictive state updates - accumulate streaming arguments and emit deltas
|
||||
# Use current_tool_call_name since content.name is only present on first chunk
|
||||
if self.current_tool_call_name and self.predict_state_config:
|
||||
# Accumulate the argument string
|
||||
if isinstance(content.arguments, str):
|
||||
self.streaming_tool_args += content.arguments
|
||||
else:
|
||||
self.streaming_tool_args += json.dumps(content.arguments)
|
||||
|
||||
logger.debug(
|
||||
f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
|
||||
)
|
||||
|
||||
# Try to parse accumulated arguments (may be incomplete JSON)
|
||||
# We use a lenient approach: try standard parsing first, then try to extract partial values
|
||||
parsed_args = None
|
||||
try:
|
||||
parsed_args = json.loads(self.streaming_tool_args)
|
||||
except json.JSONDecodeError:
|
||||
# JSON is incomplete - try to extract partial string values
|
||||
# For streaming "document" field, we can extract: {"document": "text...
|
||||
# Look for pattern: {"field": "value (incomplete)
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] == self.current_tool_call_name:
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
# Try to extract partial string value for this argument
|
||||
# Pattern: "argument_name": "partial text
|
||||
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
|
||||
match = re.search(pattern, self.streaming_tool_args)
|
||||
|
||||
if match:
|
||||
partial_value = match.group(1)
|
||||
# Unescape common sequences
|
||||
partial_value = (
|
||||
partial_value.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
||||
)
|
||||
|
||||
# Emit delta if we have new content
|
||||
if (
|
||||
state_key not in self.last_emitted_state
|
||||
or self.last_emitted_state[state_key] != partial_value
|
||||
):
|
||||
state_delta_event = StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/{state_key}",
|
||||
"value": partial_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.state_delta_count += 1
|
||||
if self.state_delta_count % 10 == 1:
|
||||
value_preview = (
|
||||
str(partial_value)[:100] + "..."
|
||||
if len(str(partial_value)) > 100
|
||||
else str(partial_value)
|
||||
)
|
||||
logger.info(
|
||||
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
|
||||
f"op=replace, path=/{state_key}, value={value_preview}"
|
||||
)
|
||||
elif self.state_delta_count % 100 == 0:
|
||||
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
|
||||
|
||||
events.append(state_delta_event)
|
||||
self.last_emitted_state[state_key] = partial_value
|
||||
self.pending_state_updates[state_key] = partial_value
|
||||
|
||||
# If we successfully parsed complete JSON, process it
|
||||
if parsed_args:
|
||||
# Check if this tool matches any predictive state config
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] == self.current_tool_call_name:
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
# Extract the state value
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Only emit if state has changed from last emission
|
||||
if (
|
||||
state_key not in self.last_emitted_state
|
||||
or self.last_emitted_state[state_key] != state_value
|
||||
):
|
||||
# Emit StateDeltaEvent for real-time UI updates (JSON Patch format)
|
||||
state_delta_event = StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace", # Use replace since field exists in schema
|
||||
"path": f"/{state_key}", # JSON Pointer path with leading slash
|
||||
"value": state_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Increment counter and log every 10th emission with sample data
|
||||
self.state_delta_count += 1
|
||||
if self.state_delta_count % 10 == 1: # Log 1st, 11th, 21st, etc.
|
||||
value_preview = (
|
||||
str(state_value)[:100] + "..."
|
||||
if len(str(state_value)) > 100
|
||||
else str(state_value)
|
||||
)
|
||||
logger.info(
|
||||
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
|
||||
f"op=replace, path=/{state_key}, value={value_preview}"
|
||||
)
|
||||
elif self.state_delta_count % 100 == 0: # Also log every 100th
|
||||
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
|
||||
|
||||
events.append(state_delta_event)
|
||||
|
||||
# Track what we emitted
|
||||
self.last_emitted_state[state_key] = state_value
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
|
||||
# Legacy predictive state check (for when arguments are complete)
|
||||
if content.name and content.arguments:
|
||||
parsed_args = content.parse_arguments()
|
||||
|
||||
if parsed_args:
|
||||
logger.info(f"Checking predict_state_config: {self.predict_state_config}")
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
logger.info(f"Checking state_key='{state_key}', config={config}")
|
||||
if config["tool"] == content.name:
|
||||
tool_arg_name = config["tool_argument"]
|
||||
logger.info(
|
||||
f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'"
|
||||
)
|
||||
|
||||
# If tool_argument is "*", use all arguments as the state value
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
logger.info(f"Using all args as state value, keys: {list(state_value.keys())}")
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
logger.info(f"Using specific arg '{tool_arg_name}' as state value")
|
||||
else:
|
||||
logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
# Emit predictive delta (JSON Patch format)
|
||||
state_delta_event = StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace", # Use replace since field exists in schema
|
||||
"path": f"/{state_key}", # JSON Pointer path with leading slash
|
||||
"value": state_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
logger.info(
|
||||
f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
|
||||
)
|
||||
events.append(state_delta_event)
|
||||
|
||||
# Track pending update for later snapshot
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
|
||||
# Note: ToolCallEndEvent is emitted when we receive FunctionResultContent,
|
||||
# not here during streaming, since we don't know when the stream is complete
|
||||
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
# First emit ToolCallEndEvent to close the tool call
|
||||
if content.call_id:
|
||||
end_event = ToolCallEndEvent(
|
||||
tool_call_id=content.call_id,
|
||||
)
|
||||
logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
|
||||
events.append(end_event)
|
||||
|
||||
# Log total StateDeltaEvent count for this tool call
|
||||
if self.state_delta_count > 0:
|
||||
logger.info(
|
||||
f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
|
||||
)
|
||||
|
||||
# Reset streaming accumulator and counter for next tool call
|
||||
self.streaming_tool_args = ""
|
||||
self.state_delta_count = 0
|
||||
|
||||
# Tool result - emit ToolCallResultEvent
|
||||
result_message_id = generate_event_id()
|
||||
|
||||
# Preserve structured data for backend tool rendering
|
||||
# Serialize dicts to JSON string, otherwise convert to string
|
||||
if isinstance(content.result, dict):
|
||||
result_content = json.dumps(content.result) # type: ignore[arg-type]
|
||||
elif content.result is not None:
|
||||
result_content = str(content.result)
|
||||
else:
|
||||
result_content = ""
|
||||
|
||||
result_event = ToolCallResultEvent(
|
||||
message_id=result_message_id,
|
||||
tool_call_id=content.call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
events.append(result_event)
|
||||
|
||||
# Track tool result for MessagesSnapshotEvent
|
||||
self.tool_results.append(
|
||||
{
|
||||
"id": result_message_id,
|
||||
"role": "tool",
|
||||
"tool_call_id": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
|
||||
# This is required for CopilotKit's useCopilotAction to detect tool result
|
||||
if self.pending_tool_calls and self.tool_results:
|
||||
# Build assistant message with tool_calls
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls
|
||||
}
|
||||
|
||||
# Build complete messages array: input messages + assistant message + tool results
|
||||
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
|
||||
|
||||
# Emit MessagesSnapshotEvent using the proper event type
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
|
||||
)
|
||||
logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
|
||||
events.append(messages_snapshot_event)
|
||||
|
||||
# After tool execution, emit StateSnapshotEvent if we have pending state updates
|
||||
if self.pending_state_updates:
|
||||
# Update the current state with pending updates
|
||||
for key, value in self.pending_state_updates.items():
|
||||
self.current_state[key] = value
|
||||
|
||||
# Log the state structure for debugging
|
||||
logger.info(f"Emitting StateSnapshotEvent with keys: {list(self.current_state.keys())}")
|
||||
if "recipe" in self.current_state:
|
||||
recipe = self.current_state["recipe"]
|
||||
logger.info(
|
||||
f"Recipe fields: title={recipe.get('title')}, "
|
||||
f"skill_level={recipe.get('skill_level')}, "
|
||||
f"ingredients_count={len(recipe.get('ingredients', []))}, "
|
||||
f"instructions_count={len(recipe.get('instructions', []))}"
|
||||
)
|
||||
|
||||
# Emit complete state snapshot
|
||||
state_snapshot_event = StateSnapshotEvent(
|
||||
snapshot=self.current_state,
|
||||
)
|
||||
events.append(state_snapshot_event)
|
||||
|
||||
# Check if this was a predictive state update tool (e.g., write_document_local)
|
||||
# If so, emit a confirm_changes tool call for the UI modal
|
||||
tool_was_predictive = False
|
||||
logger.debug(
|
||||
f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', "
|
||||
f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}"
|
||||
)
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
# Check if this tool call matches a predictive config
|
||||
# We need to match against self.current_tool_call_name
|
||||
if self.current_tool_call_name and config["tool"] == self.current_tool_call_name:
|
||||
logger.info(
|
||||
f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
|
||||
)
|
||||
tool_was_predictive = True
|
||||
break
|
||||
|
||||
if tool_was_predictive and self.require_confirmation:
|
||||
# Emit confirm_changes tool call sequence
|
||||
confirm_call_id = generate_event_id()
|
||||
|
||||
logger.info(" >>> Emitting confirm_changes tool call for predictive update")
|
||||
|
||||
# Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED)
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": confirm_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "confirm_changes",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Start the confirm_changes tool call
|
||||
confirm_start = ToolCallStartEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
tool_call_name="confirm_changes",
|
||||
)
|
||||
events.append(confirm_start)
|
||||
|
||||
# Empty args for confirm_changes
|
||||
confirm_args = ToolCallArgsEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
delta="{}",
|
||||
)
|
||||
events.append(confirm_args)
|
||||
|
||||
# End the confirm_changes tool call
|
||||
confirm_end = ToolCallEndEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
)
|
||||
events.append(confirm_end)
|
||||
|
||||
# Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED
|
||||
# Build assistant message with pending confirm_changes tool call
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes
|
||||
}
|
||||
|
||||
# Build complete messages array: input messages + assistant message + any tool results
|
||||
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
|
||||
|
||||
# Emit MessagesSnapshotEvent
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
|
||||
)
|
||||
logger.info(
|
||||
f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
|
||||
)
|
||||
events.append(messages_snapshot_event)
|
||||
|
||||
# Set flag to stop the run after this - we're waiting for user response
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info(" >>> Set flag to stop run after confirm_changes")
|
||||
elif tool_was_predictive:
|
||||
logger.info(" >>> Skipping confirm_changes - require_confirmation is False")
|
||||
|
||||
# Clear pending updates and reset tool name tracker
|
||||
self.pending_state_updates.clear()
|
||||
self.last_emitted_state.clear()
|
||||
self.current_tool_call_name = None # Reset for next tool call
|
||||
|
||||
elif isinstance(content, FunctionApprovalRequestContent):
|
||||
# Human in the loop - function approval request
|
||||
logger.info("=== FUNCTION APPROVAL REQUEST ===")
|
||||
logger.info(f" Function: {content.function_call.name}")
|
||||
logger.info(f" Call ID: {content.function_call.call_id}")
|
||||
|
||||
# Parse the arguments to extract state for predictive UI updates
|
||||
parsed_args = content.function_call.parse_arguments()
|
||||
logger.info(f" Parsed args keys: {list(parsed_args.keys()) if parsed_args else 'None'}")
|
||||
|
||||
# Check if this matches our predict_state_config and emit state
|
||||
if parsed_args and self.predict_state_config:
|
||||
logger.info(f" Checking predict_state_config: {self.predict_state_config}")
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] == content.function_call.name:
|
||||
tool_arg_name = config["tool_argument"]
|
||||
logger.info(
|
||||
f" MATCHED tool '{content.function_call.name}' for state key '{state_key}', arg='{tool_arg_name}'"
|
||||
)
|
||||
|
||||
# Extract the state value
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
# Update current state
|
||||
self.current_state[state_key] = state_value
|
||||
logger.info(
|
||||
f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
|
||||
)
|
||||
|
||||
# Emit state snapshot
|
||||
state_snapshot = StateSnapshotEvent(
|
||||
snapshot=self.current_state,
|
||||
)
|
||||
events.append(state_snapshot)
|
||||
|
||||
# The tool call has been streamed already (Start/Args events)
|
||||
# Now we need to close it with an End event before the agent waits for approval
|
||||
if content.function_call.call_id:
|
||||
end_event = ToolCallEndEvent(
|
||||
tool_call_id=content.function_call.call_id,
|
||||
)
|
||||
logger.info(
|
||||
f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
|
||||
)
|
||||
events.append(end_event)
|
||||
|
||||
# Emit custom event for approval request
|
||||
# Note: In AG-UI protocol, the frontend handles interrupts automatically
|
||||
# when it sees a tool call with the configured name (via predict_state_config)
|
||||
# This custom event is for additional metadata if needed
|
||||
approval_event = CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
"id": content.id,
|
||||
"function_call": {
|
||||
"call_id": content.function_call.call_id,
|
||||
"name": content.function_call.name,
|
||||
"arguments": content.function_call.parse_arguments(),
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'")
|
||||
events.append(approval_event)
|
||||
|
||||
return events
|
||||
|
||||
def create_run_started_event(self) -> RunStartedEvent:
|
||||
"""Create a run started event."""
|
||||
return RunStartedEvent(
|
||||
run_id=self.run_id,
|
||||
thread_id=self.thread_id,
|
||||
)
|
||||
|
||||
def create_run_finished_event(self, result: Any = None) -> RunFinishedEvent:
|
||||
"""Create a run finished event."""
|
||||
return RunFinishedEvent(
|
||||
run_id=self.run_id,
|
||||
thread_id=self.thread_id,
|
||||
result=result,
|
||||
)
|
||||
|
||||
def create_message_start_event(self, message_id: str, role: str = "assistant") -> TextMessageStartEvent:
|
||||
"""Create a message start event."""
|
||||
return TextMessageStartEvent(
|
||||
message_id=message_id,
|
||||
role=role, # type: ignore
|
||||
)
|
||||
|
||||
def create_message_end_event(self, message_id: str) -> TextMessageEndEvent:
|
||||
"""Create a message end event."""
|
||||
return TextMessageEndEvent(
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
def create_state_snapshot_event(self, state: dict[str, Any]) -> StateSnapshotEvent:
|
||||
"""Create a state snapshot event.
|
||||
|
||||
Args:
|
||||
state: The complete state snapshot.
|
||||
|
||||
Returns:
|
||||
StateSnapshotEvent.
|
||||
"""
|
||||
return StateSnapshotEvent(
|
||||
snapshot=state,
|
||||
)
|
||||
|
||||
def create_state_delta_event(self, delta: list[dict[str, Any]]) -> StateDeltaEvent:
|
||||
"""Create a state delta event using JSON Patch format (RFC 6902).
|
||||
|
||||
Args:
|
||||
delta: List of JSON Patch operations.
|
||||
|
||||
Returns:
|
||||
StateDeltaEvent.
|
||||
"""
|
||||
return StateDeltaEvent(
|
||||
delta=delta,
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Message format conversion between AG-UI and Agent Framework."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
# Role mapping constants
|
||||
_AGUI_TO_FRAMEWORK_ROLE = {
|
||||
"user": Role.USER,
|
||||
"assistant": Role.ASSISTANT,
|
||||
"system": Role.SYSTEM,
|
||||
}
|
||||
|
||||
_FRAMEWORK_TO_AGUI_ROLE = {
|
||||
Role.USER: "user",
|
||||
Role.ASSISTANT: "assistant",
|
||||
Role.SYSTEM: "system",
|
||||
}
|
||||
|
||||
|
||||
def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]:
|
||||
"""Convert AG-UI messages to Agent Framework format.
|
||||
|
||||
Args:
|
||||
messages: List of AG-UI messages
|
||||
|
||||
Returns:
|
||||
List of Agent Framework ChatMessage objects
|
||||
"""
|
||||
result: list[ChatMessage] = []
|
||||
for msg in messages:
|
||||
# Check for backend tool rendering results FIRST (may not have role field)
|
||||
if "actionExecutionId" in msg or "actionName" in msg:
|
||||
# Backend tool rendering - convert to FunctionResultContent
|
||||
from agent_framework import FunctionResultContent
|
||||
|
||||
tool_call_id = msg.get("actionExecutionId", "")
|
||||
result_content = msg.get("result", msg.get("content", ""))
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.ASSISTANT, # Tool results are assistant messages
|
||||
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
|
||||
)
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
role_str = msg.get("role", "user")
|
||||
|
||||
# Handle tool result messages (with role="tool")
|
||||
if role_str == "tool":
|
||||
# Check if this is a standard tool result (has tool_call_id or toolCallId)
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
result_content = msg.get("content", "")
|
||||
|
||||
# Distinguish between backend tool results and approval responses
|
||||
# Approval responses have {"accepted": ...} structure
|
||||
is_approval = False
|
||||
if result_content:
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed_content = json.loads(result_content)
|
||||
is_approval = "accepted" in parsed_content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
is_approval = False
|
||||
|
||||
# Backend tool results have non-empty content WITHOUT "accepted" field
|
||||
if tool_call_id and result_content and not is_approval:
|
||||
# Backend tool execution - convert to FunctionResultContent
|
||||
from agent_framework import FunctionResultContent
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.ASSISTANT, # Tool results are assistant messages
|
||||
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
|
||||
)
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
else:
|
||||
# Human-in-the-loop approval response - mark for special handling
|
||||
content = msg.get("content", "")
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER, # Approval responses are user messages
|
||||
contents=[TextContent(text=content)],
|
||||
)
|
||||
# Mark this as a tool result so we can detect it later
|
||||
chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined]
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
|
||||
# Check if this message contains function approvals
|
||||
if "function_approvals" in msg and msg["function_approvals"]:
|
||||
# Convert function approvals to FunctionApprovalResponseContent
|
||||
contents: list[Any] = []
|
||||
for approval in msg["function_approvals"]:
|
||||
# Create FunctionCallContent with the modified arguments
|
||||
func_call = FunctionCallContent(
|
||||
call_id=approval.get("call_id", ""),
|
||||
name=approval.get("name", ""),
|
||||
arguments=approval.get("arguments", {}),
|
||||
)
|
||||
|
||||
# Create the approval response
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approved=approval.get("approved", True),
|
||||
id=approval.get("id", ""),
|
||||
function_call=func_call,
|
||||
)
|
||||
contents.append(approval_response)
|
||||
|
||||
chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type]
|
||||
else:
|
||||
# Regular text message
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)])
|
||||
else:
|
||||
chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))])
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Convert Agent Framework messages to AG-UI format.
|
||||
|
||||
Args:
|
||||
messages: List of Agent Framework ChatMessage objects
|
||||
|
||||
Returns:
|
||||
List of AG-UI message dictionaries
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
|
||||
|
||||
content_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
for content in msg.contents:
|
||||
if isinstance(content, TextContent):
|
||||
content_text += content.text
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": content.call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": content.name,
|
||||
"arguments": content.arguments,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
agui_msg: dict[str, Any] = {
|
||||
"role": role,
|
||||
"content": content_text,
|
||||
}
|
||||
|
||||
if msg.message_id:
|
||||
agui_msg["id"] = msg.message_id
|
||||
|
||||
if tool_calls:
|
||||
agui_msg["tool_calls"] = tool_calls
|
||||
|
||||
result.append(agui_msg)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_text_from_contents(contents: list[Any]) -> str:
|
||||
"""Extract text from Agent Framework contents.
|
||||
|
||||
Args:
|
||||
contents: List of content objects
|
||||
|
||||
Returns:
|
||||
Concatenated text
|
||||
"""
|
||||
text_parts: list[str] = []
|
||||
for content in contents:
|
||||
if isinstance(content, TextContent):
|
||||
text_parts.append(content.text)
|
||||
elif hasattr(content, "text"):
|
||||
text_parts.append(content.text)
|
||||
return "".join(text_parts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"agui_messages_to_agent_framework",
|
||||
"agent_framework_messages_to_agui",
|
||||
"extract_text_from_contents",
|
||||
]
|
||||
@@ -0,0 +1,439 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Orchestrators for multi-turn agent flows."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
RunErrorEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from agent_framework import AgentProtocol, AgentThread, TextContent
|
||||
|
||||
from ._utils import generate_event_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._agent import AgentConfig
|
||||
from ._confirmation_strategies import ConfirmationStrategy
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecutionContext:
|
||||
"""Shared context for orchestrators."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_data: dict[str, Any],
|
||||
agent: AgentProtocol,
|
||||
config: "AgentConfig", # noqa: F821
|
||||
confirmation_strategy: "ConfirmationStrategy | None" = None, # noqa: F821
|
||||
):
|
||||
"""Initialize execution context.
|
||||
|
||||
Args:
|
||||
input_data: AG-UI run input containing messages, state, etc.
|
||||
agent: The Agent Framework agent to execute
|
||||
config: Agent configuration
|
||||
confirmation_strategy: Strategy for generating confirmation messages
|
||||
"""
|
||||
self.input_data = input_data
|
||||
self.agent = agent
|
||||
self.config = config
|
||||
self.confirmation_strategy = confirmation_strategy
|
||||
|
||||
# Lazy-loaded properties
|
||||
self._messages = None
|
||||
self._last_message = None
|
||||
self._run_id: str | None = None
|
||||
self._thread_id: str | None = None
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
"""Get converted Agent Framework messages (lazy loaded)."""
|
||||
if self._messages is None:
|
||||
from ._message_adapters import agui_messages_to_agent_framework
|
||||
|
||||
raw = self.input_data.get("messages", [])
|
||||
self._messages = agui_messages_to_agent_framework(raw)
|
||||
return self._messages
|
||||
|
||||
@property
|
||||
def last_message(self):
|
||||
"""Get the last message in the conversation (lazy loaded)."""
|
||||
if self._last_message is None and self.messages:
|
||||
self._last_message = self.messages[-1]
|
||||
return self._last_message
|
||||
|
||||
@property
|
||||
def run_id(self) -> str:
|
||||
"""Get or generate run ID."""
|
||||
if self._run_id is None:
|
||||
self._run_id = self.input_data.get("run_id") or str(uuid.uuid4())
|
||||
# This should never be None after the if block above, but satisfy type checkers
|
||||
if self._run_id is None: # pragma: no cover
|
||||
raise RuntimeError("Failed to initialize run_id")
|
||||
return self._run_id
|
||||
|
||||
@property
|
||||
def thread_id(self) -> str:
|
||||
"""Get or generate thread ID."""
|
||||
if self._thread_id is None:
|
||||
self._thread_id = self.input_data.get("thread_id") or str(uuid.uuid4())
|
||||
# This should never be None after the if block above, but satisfy type checkers
|
||||
if self._thread_id is None: # pragma: no cover
|
||||
raise RuntimeError("Failed to initialize thread_id")
|
||||
return self._thread_id
|
||||
|
||||
|
||||
class Orchestrator(ABC):
|
||||
"""Base orchestrator for agent execution flows."""
|
||||
|
||||
@abstractmethod
|
||||
def can_handle(self, context: ExecutionContext) -> bool:
|
||||
"""Determine if this orchestrator handles the current request.
|
||||
|
||||
Args:
|
||||
context: Execution context with input data and agent
|
||||
|
||||
Returns:
|
||||
True if this orchestrator should handle the request
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def run(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Execute the orchestration and yield events.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
|
||||
Yields:
|
||||
AG-UI events
|
||||
"""
|
||||
# This is never executed - just satisfies mypy's requirement for async generators
|
||||
if False: # pragma: no cover
|
||||
yield
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HumanInTheLoopOrchestrator(Orchestrator):
|
||||
"""Handles tool approval responses from user."""
|
||||
|
||||
def can_handle(self, context: ExecutionContext) -> bool:
|
||||
"""Check if last message is a tool approval response.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
|
||||
Returns:
|
||||
True if last message is a tool result
|
||||
"""
|
||||
msg = context.last_message
|
||||
if not msg or not hasattr(msg, "metadata"):
|
||||
return False
|
||||
|
||||
metadata = getattr(msg, "metadata", None)
|
||||
if not metadata:
|
||||
return False
|
||||
|
||||
return bool(metadata.get("is_tool_result", False))
|
||||
|
||||
async def run(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Process approval response and generate confirmation events.
|
||||
|
||||
This implementation is extracted from the legacy _agent.py lines 144-244.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
|
||||
Yields:
|
||||
AG-UI events (TextMessage, RunFinished)
|
||||
"""
|
||||
from ._confirmation_strategies import DefaultConfirmationStrategy
|
||||
from ._events import AgentFrameworkEventBridge
|
||||
|
||||
logger.info("=== TOOL RESULT DETECTED (HumanInTheLoopOrchestrator) ===")
|
||||
|
||||
# Create event bridge for run events
|
||||
event_bridge = AgentFrameworkEventBridge(
|
||||
run_id=context.run_id,
|
||||
thread_id=context.thread_id,
|
||||
)
|
||||
|
||||
# CRITICAL: Every AG-UI run must start with RunStartedEvent
|
||||
yield event_bridge.create_run_started_event()
|
||||
|
||||
# Get confirmation strategy (use default if none provided)
|
||||
strategy = context.confirmation_strategy
|
||||
if strategy is None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
|
||||
# Parse the tool result content
|
||||
tool_content_text = ""
|
||||
last_message = context.last_message
|
||||
if last_message:
|
||||
for content in last_message.contents:
|
||||
if isinstance(content, TextContent):
|
||||
tool_content_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
tool_result = json.loads(tool_content_text)
|
||||
accepted = tool_result.get("accepted", False)
|
||||
steps = tool_result.get("steps", [])
|
||||
|
||||
logger.info(f" Accepted: {accepted}")
|
||||
logger.info(f" Steps count: {len(steps)}")
|
||||
|
||||
# Emit a text message confirming execution
|
||||
message_id = generate_event_id()
|
||||
|
||||
yield TextMessageStartEvent(message_id=message_id, role="assistant")
|
||||
|
||||
# Check if this is confirm_changes (no steps) or function approval (has steps)
|
||||
if not steps:
|
||||
# This is confirm_changes for predictive state updates
|
||||
if accepted:
|
||||
confirmation_message = strategy.on_state_confirmed()
|
||||
else:
|
||||
confirmation_message = strategy.on_state_rejected()
|
||||
elif accepted:
|
||||
# User approved - execute the enabled steps (function approval flow)
|
||||
confirmation_message = strategy.on_approval_accepted(steps)
|
||||
else:
|
||||
# User rejected
|
||||
confirmation_message = strategy.on_approval_rejected(steps)
|
||||
|
||||
yield TextMessageContentEvent(
|
||||
message_id=message_id,
|
||||
delta=confirmation_message,
|
||||
)
|
||||
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
|
||||
# Emit run finished
|
||||
yield event_bridge.create_run_finished_event()
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse tool result: {tool_content_text}")
|
||||
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
|
||||
|
||||
class DefaultOrchestrator(Orchestrator):
|
||||
"""Standard agent execution (no special handling)."""
|
||||
|
||||
def can_handle(self, context: ExecutionContext) -> bool:
|
||||
"""Always returns True as this is the fallback orchestrator.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
|
||||
Returns:
|
||||
Always True
|
||||
"""
|
||||
return True
|
||||
|
||||
async def run(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
"""Standard agent run with event translation.
|
||||
|
||||
This implements the default agent execution flow using the event bridge
|
||||
to translate Agent Framework events to AG-UI events.
|
||||
|
||||
Args:
|
||||
context: Execution context
|
||||
|
||||
Yields:
|
||||
AG-UI events
|
||||
"""
|
||||
from ._events import AgentFrameworkEventBridge
|
||||
|
||||
logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}")
|
||||
|
||||
# Initialize state tracking
|
||||
initial_state = context.input_data.get("state", {})
|
||||
current_state: dict[str, Any] = initial_state.copy() if initial_state else {}
|
||||
|
||||
# Check if agent uses structured outputs (response_format)
|
||||
chat_options = getattr(context.agent, "chat_options", None)
|
||||
response_format = getattr(chat_options, "response_format", None) if chat_options else None
|
||||
skip_text_content = response_format is not None
|
||||
|
||||
# Create event bridge
|
||||
event_bridge = AgentFrameworkEventBridge(
|
||||
run_id=context.run_id,
|
||||
thread_id=context.thread_id,
|
||||
predict_state_config=context.config.predict_state_config,
|
||||
current_state=current_state,
|
||||
skip_text_content=skip_text_content,
|
||||
input_messages=context.input_data.get("messages", []),
|
||||
require_confirmation=context.config.require_confirmation,
|
||||
)
|
||||
|
||||
yield event_bridge.create_run_started_event()
|
||||
|
||||
# Emit PredictState custom event if we have predictive state config
|
||||
if context.config.predict_state_config:
|
||||
from ag_ui.core import CustomEvent, EventType
|
||||
|
||||
predict_state_value = [
|
||||
{
|
||||
"state_key": state_key,
|
||||
"tool": config["tool"],
|
||||
"tool_argument": config["tool_argument"],
|
||||
}
|
||||
for state_key, config in context.config.predict_state_config.items()
|
||||
]
|
||||
|
||||
yield CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name="PredictState",
|
||||
value=predict_state_value,
|
||||
)
|
||||
|
||||
# If we have a state schema, ensure we emit initial state snapshot
|
||||
if context.config.state_schema:
|
||||
# Initialize missing state fields with appropriate empty values based on schema type
|
||||
for key, schema in context.config.state_schema.items():
|
||||
if key not in current_state:
|
||||
# Default to empty object; use empty array if schema specifies "array" type
|
||||
current_state[key] = [] if isinstance(schema, dict) and schema.get("type") == "array" else {} # type: ignore
|
||||
yield event_bridge.create_state_snapshot_event(current_state)
|
||||
|
||||
# Create thread for context tracking
|
||||
thread = AgentThread()
|
||||
thread.metadata = { # type: ignore[attr-defined]
|
||||
"ag_ui_thread_id": context.thread_id,
|
||||
"ag_ui_run_id": context.run_id,
|
||||
}
|
||||
|
||||
# Inject current state into thread metadata so agent can access it
|
||||
if current_state:
|
||||
thread.metadata["current_state"] = current_state # type: ignore[attr-defined]
|
||||
|
||||
# Add incoming AG-UI messages to the thread history
|
||||
if context.messages:
|
||||
await thread.on_new_messages(context.messages)
|
||||
|
||||
# Get the last message as the new input
|
||||
new_message = context.last_message
|
||||
if not new_message:
|
||||
logger.warning("No messages provided in AG-UI input")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
# Inject current state as system message context if we have state
|
||||
messages_to_run: list[Any] = []
|
||||
if current_state and context.config.state_schema:
|
||||
state_json = json.dumps(current_state, indent=2)
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
state_context_msg = ChatMessage(
|
||||
role="system",
|
||||
contents=[
|
||||
TextContent(
|
||||
text=f"""Current state of the application:
|
||||
{state_json}
|
||||
|
||||
When modifying state, you MUST include ALL existing data plus your changes.
|
||||
For example, if adding a new ingredient, include all existing ingredients PLUS the new one.
|
||||
Never replace existing data - always append or merge."""
|
||||
)
|
||||
],
|
||||
)
|
||||
messages_to_run.append(state_context_msg)
|
||||
|
||||
messages_to_run.append(new_message)
|
||||
|
||||
# Collect all updates to get the final structured output
|
||||
all_updates: list[Any] = []
|
||||
async for update in context.agent.run_stream(messages_to_run, thread=thread):
|
||||
all_updates.append(update)
|
||||
events = await event_bridge.from_agent_run_update(update)
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
# After agent completes, check if we should stop (waiting for user to confirm changes)
|
||||
if event_bridge.should_stop_after_confirm:
|
||||
logger.info(" >>> Stopping run after confirm_changes - waiting for user response")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
# After streaming completes, check if agent has response_format and extract structured output
|
||||
if all_updates and response_format:
|
||||
from agent_framework import AgentRunResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger.info(f"Processing structured output, update count: {len(all_updates)}")
|
||||
|
||||
# Convert streaming updates to final response to get the structured output
|
||||
final_response = AgentRunResponse.from_agent_run_response_updates(
|
||||
all_updates, output_format_type=response_format
|
||||
)
|
||||
|
||||
if final_response.value and isinstance(final_response.value, BaseModel):
|
||||
# Convert Pydantic model to dict
|
||||
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
|
||||
logger.info(f"Received structured output: {list(response_dict.keys())}")
|
||||
|
||||
# Extract state fields based on state_schema
|
||||
state_updates: dict[str, Any] = {}
|
||||
|
||||
if context.config.state_schema:
|
||||
# Use state_schema to determine which fields are state
|
||||
for state_key in context.config.state_schema.keys():
|
||||
if state_key in response_dict:
|
||||
state_updates[state_key] = response_dict[state_key]
|
||||
else:
|
||||
# No schema: treat all non-message fields as state
|
||||
state_updates = {k: v for k, v in response_dict.items() if k != "message"}
|
||||
|
||||
# Apply state updates if any found
|
||||
if state_updates:
|
||||
current_state.update(state_updates)
|
||||
|
||||
# Emit StateSnapshotEvent with the updated state
|
||||
state_snapshot = event_bridge.create_state_snapshot_event(current_state)
|
||||
yield state_snapshot
|
||||
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
|
||||
|
||||
# If there's a message field, emit it as chat text
|
||||
if "message" in response_dict and response_dict["message"]:
|
||||
message_id = generate_event_id()
|
||||
yield TextMessageStartEvent(message_id=message_id, role="assistant")
|
||||
yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"])
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...")
|
||||
|
||||
if event_bridge.current_message_id:
|
||||
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
|
||||
|
||||
yield event_bridge.create_run_finished_event()
|
||||
logger.info(f"Completed agent run for thread_id={context.thread_id}, run_id={context.run_id}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Orchestrator",
|
||||
"ExecutionContext",
|
||||
"HumanInTheLoopOrchestrator",
|
||||
"DefaultOrchestrator",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Type definitions for AG-UI integration."""
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Utility functions for AG-UI integration."""
|
||||
|
||||
import copy
|
||||
import uuid
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def generate_event_id() -> str:
|
||||
"""Generate a unique event ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
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):
|
||||
return asdict(obj) # type: ignore[arg-type]
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump() # type: ignore[no-any-return]
|
||||
if hasattr(obj, "dict"):
|
||||
return obj.dict() # type: ignore[no-any-return]
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
# Marker file for PEP 561
|
||||
Reference in New Issue
Block a user