diff --git a/python/.vscode/launch.json b/python/.vscode/launch.json index b0ab97127e..4c6c3c0b01 100644 --- a/python/.vscode/launch.json +++ b/python/.vscode/launch.json @@ -12,6 +12,15 @@ "console": "integratedTerminal", "justMyCode": false }, + { + "name": "AG-UI Examples Server", + "type": "debugpy", + "request": "launch", + "module": "examples", + "cwd": "${workspaceFolder}/packages/ag-ui", + "console": "integratedTerminal", + "justMyCode": false + }, { "name": "Python Attach", "type": "debugpy", diff --git a/python/packages/ag-ui/LICENSE b/python/packages/ag-ui/LICENSE new file mode 100644 index 0000000000..22aed37e65 --- /dev/null +++ b/python/packages/ag-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md new file mode 100644 index 0000000000..7e0d6b73d9 --- /dev/null +++ b/python/packages/ag-ui/README.md @@ -0,0 +1,71 @@ +# Agent Framework AG-UI Integration + +AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol. + +## Installation + +```bash +pip install agent-framework-ag-ui +``` + +## Quick Start + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +# Create your agent +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), +) + +# Create FastAPI app and add AG-UI endpoint +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, agent, "/") + +# Run with: uvicorn main:app --reload +``` + +## Documentation + +- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client +- **[Examples](examples/)** - Complete examples for AG-UI features + +## Features + +This integration supports all 7 AG-UI features: + +1. **Agentic Chat**: Basic streaming chat with tool calling support +2. **Backend Tool Rendering**: Tools executed on backend with results streamed to client +3. **Human in the Loop**: Function approval requests for user confirmation before tool execution +4. **Agentic Generative UI**: Async tools for long-running operations with progress updates +5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls +6. **Shared State**: Bidirectional state sync between client and server +7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution + +## Architecture + +The package uses a clean, orchestrator-based architecture: + +- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators +- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.) +- **Confirmation Strategies**: Domain-specific confirmation messages (extensible) +- **AgentFrameworkEventBridge**: Converts Agent Framework events to AG-UI events +- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats +- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE) + +## Next Steps + +1. **New to AG-UI?** Start with the [Getting Started Tutorial](getting_started/) +2. **Want to see examples?** Check out the [Examples](examples/) for AG-UI features + +## License + +MIT diff --git a/python/packages/ag-ui/agent_framework_ag_ui/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py new file mode 100644 index 0000000000..1adedb2649 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/__init__.py @@ -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__", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py new file mode 100644 index 0000000000..298c0acfe9 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -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", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py b/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py new file mode 100644 index 0000000000..8bba842705 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_confirmation_strategies.py @@ -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?" diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py new file mode 100644 index 0000000000..ba6e9f5ddd --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py @@ -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)} diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py new file mode 100644 index 0000000000..b6b2294d45 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -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, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py new file mode 100644 index 0000000000..ebeb2dcacf --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -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", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py new file mode 100644 index 0000000000..1440dddf36 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -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", +] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py new file mode 100644 index 0000000000..da7d80ea66 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -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 diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py new file mode 100644 index 0000000000..e30d682fcb --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -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) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/py.typed b/python/packages/ag-ui/agent_framework_ag_ui/py.typed new file mode 100644 index 0000000000..7632ecf775 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/python/packages/ag-ui/examples/.env.example b/python/packages/ag-ui/examples/.env.example new file mode 100644 index 0000000000..ada219d9d9 --- /dev/null +++ b/python/packages/ag-ui/examples/.env.example @@ -0,0 +1,3 @@ +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_KEY=your-api-key-here +PORT=8000 diff --git a/python/packages/ag-ui/examples/.vscode/settings.json b/python/packages/ag-ui/examples/.vscode/settings.json new file mode 100644 index 0000000000..0728fcf794 --- /dev/null +++ b/python/packages/ag-ui/examples/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.analysis.extraPaths": [ + "${workspaceFolder}/packages/ag-ui/examples" + ] +} diff --git a/python/packages/ag-ui/examples/README.md b/python/packages/ag-ui/examples/README.md new file mode 100644 index 0000000000..88887f6070 --- /dev/null +++ b/python/packages/ag-ui/examples/README.md @@ -0,0 +1,243 @@ +# Agent Framework AG-UI Integration + +AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol. + +## Installation + +```bash +pip install agent-framework-ag-ui +``` + +## Quick Start + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +# Create your agent +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +# Create FastAPI app and add AG-UI endpoint +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, agent, "/agent") + +# Run with: uvicorn main:app --reload +``` + +## Features + +This integration supports all 7 AG-UI features: + +1. **Agentic Chat**: Basic streaming chat with tool calling support +2. **Backend Tool Rendering**: Tools executed on backend with results streamed via ToolCallResultEvent +3. **Human in the Loop**: Function approval requests for user confirmation before tool execution +4. **Agentic Generative UI**: Async tools for long-running operations with progress updates +5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls +6. **Shared State**: Bidirectional state sync using StateSnapshotEvent and StateDeltaEvent +7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution + +## Examples + +Complete examples for all features are in the `examples/` directory: + +- `examples/agents/simple_agent.py` - Basic agentic chat +- `examples/agents/weather_agent.py` - Backend tool rendering +- `examples/agents/task_planner_agent.py` - Human in the loop with approvals +- `examples/agents/research_assistant_agent.py` - Agentic generative UI +- `examples/agents/ui_generator_agent.py` - Tool-based generative UI +- `examples/agents/recipe_agent.py` - Shared state management +- `examples/agents/document_writer_agent.py` - Predictive state updates +- `examples/server/main.py` - FastAPI server with all endpoints + +Run the example server: + +```bash +cd examples/server +uvicorn main:app --reload +``` + +To enable debug logging: + +```bash +ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload +``` + +The server exposes endpoints at: +- `/agentic_chat` +- `/backend_tool_rendering` +- `/human_in_the_loop` +- `/agentic_generative_ui` +- `/tool_based_generative_ui` +- `/shared_state` +- `/predictive_state_updates` + +## Architecture + +The package uses a clean, orchestrator-based architecture: + +- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators +- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.) +- **Confirmation Strategies**: Domain-specific confirmation messages (extensible) +- **AgentFrameworkEventBridge**: Converts AgentRunResponseUpdate to AG-UI events +- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats +- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE) + +### Key Design Patterns + +- **Orchestrator Pattern**: Separates flow control from protocol translation +- **Strategy Pattern**: Pluggable confirmation message strategies +- **Context Object**: Lazy-loaded execution context passed to orchestrators +- **Event Bridge**: Stateless translation of Agent Framework events to AG-UI events + +## Advanced Usage + +### Shared State + +State is injected as system messages and updated via predictive state updates: + +```python +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent + +# Create your agent +agent = ChatAgent( + name="recipe_agent", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +state_schema = { + "recipe": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "ingredients": {"type": "array"} + } + } +} + +# Configure which tool updates which state fields +predict_state_config = { + "recipe": {"tool": "update_recipe", "tool_argument": "recipe_data"} +} + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema=state_schema, + predict_state_config=predict_state_config, +) +``` + +### Predictive State Updates + +Predictive state updates automatically stream tool arguments as optimistic state updates: + +```python +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent + +# Create your agent +agent = ChatAgent( + name="document_writer", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +predict_state_config = { + "current_title": {"tool": "write_document", "tool_argument": "title"}, + "current_content": {"tool": "write_document", "tool_argument": "content"}, +} + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema={"current_title": {"type": "string"}, "current_content": {"type": "string"}}, + predict_state_config=predict_state_config, + require_confirmation=True, # User can approve/reject changes +) +``` + +### Custom Confirmation Strategies + +Provide domain-specific confirmation messages: + +```python +from typing import Any +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent, ConfirmationStrategy + +class CustomConfirmationStrategy(ConfirmationStrategy): + def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str: + return "Your custom approval message!" + + def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str: + return "Your custom rejection message!" + + def on_state_confirmed(self) -> str: + return "State changes confirmed!" + + def on_state_rejected(self) -> str: + return "State changes rejected!" + +agent = ChatAgent( + name="custom_agent", + chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), +) + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + confirmation_strategy=CustomConfirmationStrategy(), +) +``` + +### Human in the Loop + +Human-in-the-loop is automatically handled when tools are marked for approval: + +```python +from agent_framework import ai_function + +@ai_function(approval_mode="always_require") +def sensitive_action(param: str) -> str: + """This action requires user approval.""" + return f"Executed with {param}" + +# The orchestrator automatically detects approval responses and handles them +``` + +### Custom Orchestrators + +Add custom execution flows by implementing the Orchestrator pattern: + +```python +from agent_framework_ag_ui._orchestrators import Orchestrator, ExecutionContext + +class MyCustomOrchestrator(Orchestrator): + def can_handle(self, context: ExecutionContext) -> bool: + # Return True if this orchestrator should handle the request + return context.input_data.get("custom_mode") == True + + async def run(self, context: ExecutionContext): + # Custom execution logic + yield RunStartedEvent(...) + # ... your custom flow + yield RunFinishedEvent(...) + +wrapped_agent = AgentFrameworkAgent( + agent=your_agent, + orchestrators=[MyCustomOrchestrator(), DefaultOrchestrator()], +) + +## Documentation + +For detailed documentation, see [DESIGN.md](DESIGN.md). + +## License + +MIT diff --git a/python/packages/ag-ui/examples/__init__.py b/python/packages/ag-ui/examples/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/examples/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/examples/__main__.py b/python/packages/ag-ui/examples/__main__.py new file mode 100644 index 0000000000..b52cf15cc0 --- /dev/null +++ b/python/packages/ag-ui/examples/__main__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Entry point for running the AG-UI examples server as a module.""" + +from .server.main import main + +if __name__ == "__main__": + main() diff --git a/python/packages/ag-ui/examples/agents/__init__.py b/python/packages/ag-ui/examples/agents/__init__.py new file mode 100644 index 0000000000..eea1a10956 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agents for AG-UI demonstration.""" diff --git a/python/packages/ag-ui/examples/agents/document_writer_agent.py b/python/packages/ag-ui/examples/agents/document_writer_agent.py new file mode 100644 index 0000000000..ca7233a5a3 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/document_writer_agent.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating predictive state updates with document writing.""" + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy + + +@ai_function +def write_document_local(document: str) -> str: + """Write a document. Use markdown formatting to format the document. + + It's good to format the document extensively so it's easy to read. + You can use all kinds of markdown. + However, do not use italic or strike-through formatting, it's reserved for another purpose. + You MUST write the full document, even when changing only a few words. + When making edits to the document, try to make them minimal - do not change every word. + Keep stories SHORT! + + Args: + document: The complete document content in markdown format + + Returns: + Confirmation that the document was written + """ + return "Document written." + + +agent = ChatAgent( + name="document_writer", + instructions=( + "You are a helpful assistant for writing documents. " + "To write the document, you MUST use the write_document_local tool. " + "You MUST write the full document, even when changing only a few words. " + "When you wrote the document, DO NOT repeat it as a message. " + "Just briefly summarize the changes you made. 2 sentences max. " + "\n\n" + "The current state of the document will be provided to you. " + "When editing, make minimal changes - do not change every word unless requested." + ), + chat_client=AzureOpenAIChatClient(), + tools=[write_document_local], +) + +document_writer_agent = AgentFrameworkAgent( + agent=agent, + name="DocumentWriter", + description="Writes and edits documents with predictive state updates", + state_schema={ + "document": {"type": "string", "description": "The current document content"}, + }, + predict_state_config={ + "document": {"tool": "write_document_local", "tool_argument": "document"}, + }, + confirmation_strategy=DocumentWriterConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py b/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py new file mode 100644 index 0000000000..dfa1b30c63 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/human_in_the_loop_agent.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop agent demonstrating step customization (Feature 5).""" + +from enum import Enum + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + + +class StepStatus(str, Enum): + """Status of a task step.""" + + ENABLED = "enabled" + DISABLED = "disabled" + + +class TaskStep(BaseModel): + """A single step in a task execution plan.""" + + description: str = Field(..., description="The text of the step in imperative form (e.g., 'Dig hole', 'Open door')") + status: StepStatus = Field(default=StepStatus.ENABLED, description="Whether the step is enabled or disabled") + + +@ai_function( + name="generate_task_steps", + description="Generate execution steps for a task", + approval_mode="always_require", +) +def generate_task_steps(steps: list[TaskStep]) -> str: + """Make up 10 steps (only a couple of words per step) that are required for a task. + + The step should be in imperative form (i.e. Dig hole, Open door, ...). + Each step will have status='enabled' by default. + + Args: + steps: An array of 10 step objects, each containing description and status + + Returns: + Confirmation message + """ + return f"Generated {len(steps)} execution steps for the task." + + +# Create the human-in-the-loop agent using tool-based approach for predictive state +human_in_the_loop_agent = ChatAgent( + name="human_in_the_loop_agent", + instructions="""You are a helpful assistant that can perform any task by breaking it down into steps. + + When asked to perform a task, you MUST call the `generate_task_steps` function with the proper + number of steps per the request. + + Rules for steps: + - Each step description should be in imperative form (e.g., "Dig hole", "Open door", "Prepare ingredients") + - Each step should be brief (only a couple of words) + - All steps must have status='enabled' initially + + Example steps for "Build a robot": + 1. "Design blueprint" + 2. "Gather components" + 3. "Assemble frame" + 4. "Install motors" + 5. "Wire electronics" + 6. "Program controller" + 7. "Test movements" + 8. "Add sensors" + 9. "Calibrate systems" + 10. "Final testing" + + After calling the function, provide a brief acknowledgment like: + "I've created a plan with 10 steps. You can customize which steps to enable before I proceed." + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_task_steps], +) diff --git a/python/packages/ag-ui/examples/agents/recipe_agent.py b/python/packages/ag-ui/examples/agents/recipe_agent.py new file mode 100644 index 0000000000..2a5b94e1cc --- /dev/null +++ b/python/packages/ag-ui/examples/agents/recipe_agent.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Recipe agent example demonstrating shared state management (Feature 3).""" + +from enum import Enum + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + +from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy + + +class SkillLevel(str, Enum): + """The skill level required for the recipe.""" + + BEGINNER = "Beginner" + INTERMEDIATE = "Intermediate" + ADVANCED = "Advanced" + + +class CookingTime(str, Enum): + """The cooking time of the recipe.""" + + FIVE_MIN = "5 min" + FIFTEEN_MIN = "15 min" + THIRTY_MIN = "30 min" + FORTY_FIVE_MIN = "45 min" + SIXTY_PLUS_MIN = "60+ min" + + +class Ingredient(BaseModel): + """An ingredient with its details.""" + + icon: str = Field(..., description="Emoji icon representing the ingredient (e.g., 🥕)") + name: str = Field(..., description="Name of the ingredient") + amount: str = Field(..., description="Amount or quantity of the ingredient") + + +class Recipe(BaseModel): + """A complete recipe.""" + + title: str = Field(..., description="The title of the recipe") + skill_level: SkillLevel = Field(..., description="The skill level required") + special_preferences: list[str] = Field( + default_factory=list, description="Dietary preferences (e.g., Vegetarian, Gluten-free)" + ) + cooking_time: CookingTime = Field(..., description="The estimated cooking time") + ingredients: list[Ingredient] = Field(..., description="Complete list of ingredients") + instructions: list[str] = Field(..., description="Step-by-step cooking instructions") + + +@ai_function +def update_recipe(recipe: Recipe) -> str: + """Update the recipe with new or modified content. + + You MUST write the complete recipe with ALL fields, even when changing only a few items. + When modifying an existing recipe, include ALL existing ingredients and instructions plus your changes. + NEVER delete existing data - only add or modify. + + Args: + recipe: The complete recipe object with all details + + Returns: + Confirmation that the recipe was updated + """ + return "Recipe updated." + + +# Create the recipe agent using tool-based approach for streaming +agent = ChatAgent( + name="recipe_agent", + instructions="""You are a helpful recipe assistant that creates and modifies recipes. + + CRITICAL RULES: + 1. You will receive the current recipe state in the system context + 2. To update the recipe, you MUST use the update_recipe tool + 3. When modifying a recipe, ALWAYS include ALL existing data plus your changes in the tool call + 4. NEVER delete existing ingredients or instructions - only add or modify + 5. After calling the tool, provide a brief conversational message (1-2 sentences) + + When creating a NEW recipe: + - Provide all required fields: title, skill_level, cooking_time, ingredients, instructions + - Use actual emojis for ingredient icons (🥕 🧄 🧅 🍅 🌿 🍗 🥩 🧀) + - Leave special_preferences empty unless specified + - Message: "Here's your recipe!" or similar + + When MODIFYING or IMPROVING an existing recipe: + - Include ALL existing ingredients + any new ones + - Include ALL existing instructions + any new/modified ones + - Update other fields as needed + - Message: Explain what you improved (e.g., "I upgraded the ingredients to premium quality") + - When asked to "improve", enhance with: + * Better ingredients (upgrade quality, add complementary flavors) + * More detailed instructions + * Professional techniques + * Adjust skill_level if complexity changes + * Add relevant special_preferences + + Example improvements: + - Upgrade "chicken" → "organic free-range chicken breast" + - Add herbs: basil, oregano, thyme + - Add aromatics: garlic, shallots + - Add finishing touches: lemon zest, fresh parsley + - Make instructions more detailed and professional + """, + chat_client=AzureOpenAIChatClient(), + tools=[update_recipe], +) + +recipe_agent = AgentFrameworkAgent( + agent=agent, + name="RecipeAgent", + description="Creates and modifies recipes with streaming state updates", + state_schema={ + "recipe": {"type": "object", "description": "The current recipe"}, + }, + predict_state_config={ + "recipe": {"tool": "update_recipe", "tool_argument": "recipe"}, + }, + confirmation_strategy=RecipeConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/research_assistant_agent.py b/python/packages/ag-ui/examples/agents/research_assistant_agent.py new file mode 100644 index 0000000000..60d142e2c2 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/research_assistant_agent.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating agentic generative UI with custom events during execution.""" + +import asyncio + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent + + +@ai_function +async def research_topic(topic: str) -> str: + """Research a topic and generate a comprehensive report. + + Args: + topic: The topic to research + + Returns: + Research report + """ + # Simulate multi-step research process + steps = [ + ("Searching databases", 1.0), + ("Analyzing sources", 1.5), + ("Synthesizing information", 1.0), + ("Generating report", 0.5), + ] + + results: list[str] = [] + for step_name, duration in steps: + await asyncio.sleep(duration) + results.append(f"- {step_name}: completed") + + return f"Research report on '{topic}':\n" + "\n".join(results) + + +@ai_function +async def create_presentation(title: str, num_slides: int) -> str: + """Create a presentation with multiple slides. + + Args: + title: Presentation title + num_slides: Number of slides to create + + Returns: + Presentation summary + """ + # Simulate slide generation + slides: list[str] = [] + for i in range(num_slides): + await asyncio.sleep(0.5) + slides.append(f"Slide {i + 1}: Content for {title}") + + return f"Created presentation '{title}' with {num_slides} slides:\n" + "\n".join(slides) + + +@ai_function +async def analyze_data(dataset: str) -> str: + """Analyze a dataset and produce insights. + + Args: + dataset: The dataset name to analyze + + Returns: + Analysis results + """ + # Simulate data analysis phases + phases = [ + ("Loading data", 0.8), + ("Cleaning data", 1.0), + ("Running statistical analysis", 1.2), + ("Generating visualizations", 0.7), + ] + + insights: list[str] = [] + for phase_name, duration in phases: + await asyncio.sleep(duration) + insights.append(f"- {phase_name}: done") + + return f"Analysis of '{dataset}':\n" + "\n".join(insights) + + +agent = ChatAgent( + name="research_assistant", + instructions=( + "You are a research and analysis assistant. " + "You can research topics, create presentations, and analyze data. " + "Use the available tools to help users with their research needs." + ), + chat_client=AzureOpenAIChatClient(), + tools=[research_topic, create_presentation, analyze_data], +) + +research_assistant_agent = AgentFrameworkAgent( + agent=agent, + name="ResearchAssistant", + description="Research assistant that emits progress events during task execution", +) diff --git a/python/packages/ag-ui/examples/agents/simple_agent.py b/python/packages/ag-ui/examples/agents/simple_agent.py new file mode 100644 index 0000000000..4831f1442c --- /dev/null +++ b/python/packages/ag-ui/examples/agents/simple_agent.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Simple agentic chat example (Feature 1: Agentic Chat).""" + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient + +# Create a simple chat agent +agent = ChatAgent( + name="simple_chat_agent", + instructions="You are a helpful assistant. Be concise and friendly.", + chat_client=AzureOpenAIChatClient(), +) diff --git a/python/packages/ag-ui/examples/agents/task_planner_agent.py b/python/packages/ag-ui/examples/agents/task_planner_agent.py new file mode 100644 index 0000000000..58d8b8c556 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/task_planner_agent.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating human-in-the-loop with function approvals.""" + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy + + +@ai_function(approval_mode="always_require") +def create_calendar_event(title: str, date: str, time: str) -> str: + """Create a calendar event. + + Args: + title: The event title + date: The event date (YYYY-MM-DD) + time: The event time (HH:MM) + + Returns: + Confirmation message + """ + return f"Calendar event '{title}' created for {date} at {time}" + + +@ai_function(approval_mode="always_require") +def send_email(to: str, subject: str, body: str) -> str: + """Send an email. + + Args: + to: Recipient email address + subject: Email subject + body: Email body text + + Returns: + Confirmation message + """ + return f"Email sent to {to} with subject '{subject}'" + + +@ai_function(approval_mode="always_require") +def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) -> str: + """Book a meeting room. + + Args: + room_name: The meeting room name + date: The booking date (YYYY-MM-DD) + start_time: Start time (HH:MM) + end_time: End time (HH:MM) + + Returns: + Confirmation message + """ + return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}" + + +agent = ChatAgent( + name="task_planner", + instructions=( + "You are a helpful assistant that plans and executes tasks. " + "You have access to calendar, email, and meeting room booking functions. " + "All of these actions require user approval before execution." + ), + chat_client=AzureOpenAIChatClient(), + tools=[create_calendar_event, send_email, book_meeting_room], +) + +task_planner_agent = AgentFrameworkAgent( + agent=agent, + name="TaskPlanner", + description="Plans and executes tasks with user approval", + confirmation_strategy=TaskPlannerConfirmationStrategy(), +) diff --git a/python/packages/ag-ui/examples/agents/task_steps_agent.py b/python/packages/ag-ui/examples/agents/task_steps_agent.py new file mode 100644 index 0000000000..ef7a438d9b --- /dev/null +++ b/python/packages/ag-ui/examples/agents/task_steps_agent.py @@ -0,0 +1,318 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Task steps agent demonstrating agentic generative UI (Feature 6).""" + +import asyncio +from collections.abc import AsyncGenerator +from enum import Enum +from typing import Any + +from ag_ui.core import ( + EventType, + MessagesSnapshotEvent, + RunFinishedEvent, + StateDeltaEvent, + StateSnapshotEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallStartEvent, +) +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient +from pydantic import BaseModel, Field + +from agent_framework_ag_ui import AgentFrameworkAgent + + +class StepStatus(str, Enum): + """Status of a task step.""" + + PENDING = "pending" + COMPLETED = "completed" + + +class TaskStep(BaseModel): + """A single step in a task.""" + + description: str = Field( + ..., description="The text of the step in gerund form (e.g., 'Digging hole', 'Opening door')" + ) + status: StepStatus = Field(default=StepStatus.PENDING, description="The status of the step") + + +@ai_function +def generate_task_steps(steps: list[TaskStep]) -> str: + """Generate a list of task steps for completing a task. + + Args: + steps: Complete list of task steps with descriptions and status + + Returns: + Confirmation that steps were generated + """ + return "Steps generated." + + +# Create the task steps agent using tool-based approach for streaming +agent = ChatAgent( + name="task_steps_agent", + instructions="""You are a helpful assistant that breaks down tasks into actionable steps. + + When asked to perform a task, you MUST: + 1. Use the generate_task_steps tool to create the steps + 2. Pay attention to how many steps the user requests (if specified) + 3. If no specific number is mentioned, use a reasonable number of steps (typically 5-10) + 4. Each step description should be in gerund form (e.g., "Designing spacecraft", "Training astronauts") + 5. Each step should be brief (only 2-4 words) + 6. All steps must have status='pending' + 7. After calling the tool, provide a brief conversational message (one sentence) saying you created the plan + + Example steps for "Build a treehouse in 5 steps": + - "Selecting location" + - "Gathering materials" + - "Assembling frame" + - "Installing platform" + - "Adding finishing touches" + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_task_steps], +) + +task_steps_agent = AgentFrameworkAgent( + agent=agent, + name="TaskStepsAgent", + description="Generates task steps with streaming state updates", + state_schema={ + "steps": {"type": "array", "description": "The list of task steps"}, + }, + predict_state_config={ + "steps": { + "tool": "generate_task_steps", + "tool_argument": "steps", + } + }, + require_confirmation=False, # Agentic generative UI updates automatically without confirmation +) + + +# Wrap the agent's run method to add step execution simulation +class TaskStepsAgentWithExecution: + """Wrapper that adds step execution simulation after plan generation. + + This wrapper delegates to AgentFrameworkAgent but is recognized as compatible + by add_agent_framework_fastapi_endpoint since it implements run_agent(). + """ + + def __init__(self, base_agent: AgentFrameworkAgent): + """Initialize wrapper with base agent.""" + self._base_agent = base_agent + + @property + def name(self) -> str: + """Delegate to base agent.""" + return self._base_agent.name + + @property + def description(self) -> str: + """Delegate to base agent.""" + return self._base_agent.description + + def __getattr__(self, name: str) -> Any: + """Delegate all other attribute access to base agent.""" + return getattr(self._base_agent, name) + + async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, None]: + """Run the agent and then simulate step execution.""" + import logging + import uuid + + logger = logging.getLogger(__name__) + logger.info(">>> TaskStepsAgentWithExecution.run_agent() called - wrapper is active") + + # First, run the base agent to generate the plan - buffer text messages + final_state: dict[str, Any] | None = None + run_finished_event: Any = None + tool_call_id: str | None = None + buffered_text_events: list[Any] = [] # Buffer text from first LLM call + + async for event in self._base_agent.run_agent(input_data): + event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__ + logger.info(f">>> Processing event: {event_type_str}") + + match event: + case StateSnapshotEvent(snapshot=snapshot): + final_state = snapshot + logger.info(f">>> Captured STATE_SNAPSHOT event with state: {final_state}") + yield event + case RunFinishedEvent(): + run_finished_event = event + logger.info(">>> Captured RUN_FINISHED event - will send after step execution and summary") + case ToolCallStartEvent(tool_call_id=call_id): + tool_call_id = call_id + logger.info(f">>> Captured tool_call_id: {tool_call_id}") + yield event + case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent(): + buffered_text_events.append(event) + logger.info(f">>> Buffered {event_type_str} from first LLM call") + case _: + logger.info(f">>> Yielding event immediately: {event_type_str}") + yield event + + logger.info(f">>> Base agent completed. Final state: {final_state}") + + # Now simulate executing the steps + if final_state and "steps" in final_state: + steps = final_state["steps"] + logger.info(f">>> Starting step execution simulation for {len(steps)} steps") + + for i in range(len(steps)): + logger.info(f">>> Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}") + await asyncio.sleep(1.0) # Simulate work + + # Update step to completed + steps[i]["status"] = "completed" + logger.info(f">>> Step {i + 1} marked as completed") + + # Send delta event with manual JSON patch format + delta_event = StateDeltaEvent( + type=EventType.STATE_DELTA, + delta=[ + { + "op": "replace", + "path": f"/steps/{i}/status", + "value": "completed", + } + ], + ) + logger.info(f">>> Yielding StateDeltaEvent for step {i + 1}") + yield delta_event + + # Send final snapshot + final_snapshot = StateSnapshotEvent( + type=EventType.STATE_SNAPSHOT, + snapshot={"steps": steps}, + ) + logger.info(">>> Yielding final StateSnapshotEvent with all steps completed") + yield final_snapshot + + # SECOND LLM call: Stream summary from chat client directly + logger.info(">>> Making SECOND LLM call to generate summary after step execution") + + # Get the underlying chat agent and client + chat_agent = self._base_agent.agent # type: ignore + chat_client = chat_agent.chat_client # type: ignore + + # Build messages for summary call + from agent_framework._types import ChatMessage, TextContent + + original_messages = input_data.get("messages", []) + + # Convert to ChatMessage objects if needed + messages: list[ChatMessage] = [] + for msg in original_messages: + if isinstance(msg, dict): + content_str = msg.get("content", "") + if isinstance(content_str, str): + messages.append( + ChatMessage( + role=msg.get("role", "user"), + contents=[TextContent(text=content_str)], + ) + ) + elif isinstance(msg, ChatMessage): + messages.append(msg) + + # Add completion message + messages.append( + ChatMessage( + role="user", + contents=[ + TextContent( + text="The steps have been successfully executed. Provide a brief one-sentence summary." + ) + ], + ) + ) + + # Stream the LLM response and manually emit text events + logger.info(">>> Calling chat client for summary") + + message_id = str(uuid.uuid4()) + + try: + # Emit TEXT_MESSAGE_START + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=message_id, + role="assistant", + ) + # Small delay to ensure START event is processed before CONTENT events + await asyncio.sleep(0.01) + + # Stream completion + accumulated_text = "" + async for chunk in chat_client.get_streaming_response(messages=messages): + # chunk is ChatResponseUpdate + if hasattr(chunk, "text") and chunk.text: + accumulated_text += chunk.text + # Emit TEXT_MESSAGE_CONTENT + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=message_id, + delta=chunk.text, + ) + + # Emit TEXT_MESSAGE_END + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + ) + logger.info(f">>> Summary complete: {accumulated_text}") + + # Build complete message for persistence + summary_message = { + "role": "assistant", + "content": accumulated_text, + "id": message_id, + } + final_messages = list(original_messages) + final_messages.append(summary_message) + + # Emit MessagesSnapshotEvent to persist in history + yield MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=final_messages, + ) + except Exception as e: + logger.error(f">>> Error generating summary: {e}") + # Generate a new message ID for the error + error_message_id = str(uuid.uuid4()) + # Yield TEXT_MESSAGE_START for error + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=error_message_id, + role="assistant", + ) + # Yield error message content + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=error_message_id, + delta=f"[Summary generation error: {e!s}]", + ) + # Yield TEXT_MESSAGE_END for error + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=error_message_id, + ) + else: + logger.warning(f">>> No steps found in final_state to execute. final_state={final_state}") + + # Finally send the original RUN_FINISHED event + if run_finished_event: + logger.info(">>> Yielding original RUN_FINISHED event") + yield run_finished_event + + +# Export the wrapped agent +task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent) diff --git a/python/packages/ag-ui/examples/agents/ui_generator_agent.py b/python/packages/ag-ui/examples/agents/ui_generator_agent.py new file mode 100644 index 0000000000..2456ccb5e1 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/ui_generator_agent.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example agent demonstrating Tool-based Generative UI (Feature 5).""" + +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + +from agent_framework_ag_ui import AgentFrameworkAgent + + +@ai_function +def generate_haiku(english: list[str], japanese: list[str], image_name: str | None, gradient: str) -> str: + """Generate a haiku with image and gradient background (FRONTEND_RENDER). + + This tool generates UI for displaying a haiku with an image and gradient background. + The frontend should render this as a custom haiku component. + + Args: + english: English haiku lines (exactly 3 lines) + japanese: Japanese haiku lines (exactly 3 lines) + image_name: Image filename for visual accompaniment. Must be one of: + - "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg" + - "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg" + - "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg" + - "Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg" + - "Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg" + - "Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg" + - "Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg" + - "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg" + - "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg" + - "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg" + gradient: CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)") + + Returns: + Haiku metadata for frontend rendering + """ + return f"Haiku generated with image: {image_name}" + + +@ai_function +def create_chart(chart_type: str, data_points: list[dict[str, Any]], title: str) -> str: + """Create an interactive chart (FRONTEND_RENDER). + + This tool creates chart specifications for frontend rendering. + The frontend should render this as an interactive chart component. + + Args: + chart_type: Type of chart (bar, line, pie, scatter) + data_points: Data points for the chart + title: Chart title + + Returns: + Chart specification for frontend rendering + """ + return f"Chart '{title}' created with {len(data_points)} data points" + + +@ai_function +def display_timeline(events: list[dict[str, Any]], start_date: str, end_date: str) -> str: + """Display an interactive timeline (FRONTEND_RENDER). + + This tool creates timeline specifications for frontend rendering. + The frontend should render this as an interactive timeline component. + + Args: + events: Events to display on the timeline + start_date: Timeline start date + end_date: Timeline end date + + Returns: + Timeline specification for frontend rendering + """ + return f"Timeline created with {len(events)} events from {start_date} to {end_date}" + + +@ai_function +def show_comparison_table(items: list[dict[str, Any]], columns: list[str]) -> str: + """Show a comparison table (FRONTEND_RENDER). + + This tool creates table specifications for frontend rendering. + The frontend should render this as an interactive comparison table. + + Args: + items: Items to compare + columns: Column names + + Returns: + Table specification for frontend rendering + """ + return f"Comparison table created with {len(items)} items and {len(columns)} columns" + + +# Create the UI generator agent using tool-based approach with forced tool usage +agent = ChatAgent( + name="ui_generator", + instructions="""You MUST use the provided tools to generate content. Never respond with plain text descriptions. + + For haiku requests: + - Call generate_haiku tool with all 4 required parameters + - English: 3 lines + - Japanese: 3 lines + - image_name: Choose from available images + - gradient: CSS gradient string + + For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table). + """, + chat_client=AzureOpenAIChatClient(), + tools=[generate_haiku, create_chart, display_timeline, show_comparison_table], + # Force tool usage - the LLM MUST call a tool, cannot respond with plain text + chat_options={"tool_choice": "required"}, +) + +ui_generator_agent = AgentFrameworkAgent( + agent=agent, + name="UIGenerator", + description="Generates custom UI components through tool calls", +) diff --git a/python/packages/ag-ui/examples/agents/weather_agent.py b/python/packages/ag-ui/examples/agents/weather_agent.py new file mode 100644 index 0000000000..a224bb7cd0 --- /dev/null +++ b/python/packages/ag-ui/examples/agents/weather_agent.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Weather agent example demonstrating backend tool rendering.""" + +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + + +@ai_function +def get_weather(location: str) -> dict[str, Any]: + """Get the current weather for a location. + + Args: + location: The city or location to get weather for. + + Returns: + Weather information as a dictionary with temperatures in Celsius. + """ + # Simulated weather data with structured format (temperatures in Celsius for dojo UI) + weather_data = { + "seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75, "wind_speed": 12, "feels_like": 10}, + "san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85, "wind_speed": 8, "feels_like": 13}, + "new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60, "wind_speed": 10, "feels_like": 17}, + "miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90, "wind_speed": 5, "feels_like": 32}, + "chicago": {"temperature": 9, "conditions": "windy", "humidity": 65, "wind_speed": 20, "feels_like": 6}, + } + + location_lower = location.lower() + if location_lower in weather_data: + return weather_data[location_lower] + + return { + "temperature": 21, + "conditions": "partly cloudy", + "humidity": 50, + "wind_speed": 10, + "feels_like": 20, + } + + +@ai_function +def get_forecast(location: str, days: int = 3) -> str: + """Get the weather forecast for a location. + + Args: + location: The city or location to get forecast for. + days: Number of days to forecast (default: 3). + + Returns: + Forecast information string. + """ + forecast: list[str] = [] + for day in range(1, min(days, 7) + 1): + forecast.append(f"Day {day}: Partly cloudy, {60 + day * 2}°F") + + return f"{days}-day forecast for {location}:\n" + "\n".join(forecast) + + +# Create the weather agent +weather_agent = ChatAgent( + name="weather_agent", + instructions=( + "You are a helpful weather assistant. " + "Use the get_weather and get_forecast functions to help users with weather information. " + "Always provide friendly and informative responses." + ), + chat_client=AzureOpenAIChatClient(), + tools=[get_weather, get_forecast], +) diff --git a/python/packages/ag-ui/examples/server/__init__.py b/python/packages/ag-ui/examples/server/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/examples/server/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/examples/server/api/__init__.py b/python/packages/ag-ui/examples/server/api/__init__.py new file mode 100644 index 0000000000..e50a96d510 --- /dev/null +++ b/python/packages/ag-ui/examples/server/api/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""API endpoints for AG-UI examples.""" diff --git a/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py new file mode 100644 index 0000000000..fb8f88e6a4 --- /dev/null +++ b/python/packages/ag-ui/examples/server/api/backend_tool_rendering.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Backend tool rendering endpoint.""" + +from fastapi import FastAPI + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +from ...agents.weather_agent import weather_agent + + +def register_backend_tool_rendering(app: FastAPI) -> None: + """Register the backend tool rendering endpoint. + + Args: + app: The FastAPI application. + """ + add_agent_framework_fastapi_endpoint( + app, + weather_agent, + "/backend_tool_rendering", + ) diff --git a/python/packages/ag-ui/examples/server/main.py b/python/packages/ag-ui/examples/server/main.py new file mode 100644 index 0000000000..6841f3db20 --- /dev/null +++ b/python/packages/ag-ui/examples/server/main.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example FastAPI server with AG-UI endpoints.""" + +import logging +import os + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +from ..agents.document_writer_agent import document_writer_agent +from ..agents.human_in_the_loop_agent import human_in_the_loop_agent +from ..agents.recipe_agent import recipe_agent +from ..agents.simple_agent import agent as simple_agent +from ..agents.task_steps_agent import task_steps_agent_wrapped as task_steps_agent # Custom wrapper +from ..agents.ui_generator_agent import ui_generator_agent +from ..agents.weather_agent import weather_agent + +# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable) +if os.getenv("ENABLE_DEBUG_LOGGING"): + log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log") + + # Remove any existing handlers + root_logger = logging.getLogger() + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Configure new handlers + file_handler = logging.FileHandler(log_file, mode="w") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) + + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + root_logger.setLevel(logging.INFO) + + # Explicitly set log levels for our modules + logging.getLogger("agent_framework_ag_ui").setLevel(logging.INFO) + logging.getLogger("agent_framework").setLevel(logging.INFO) + + logger = logging.getLogger(__name__) + logger.info(f"AG-UI Examples Server starting... Logs writing to: {log_file}") + +app = FastAPI(title="Agent Framework AG-UI Example Server") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Agentic Chat - basic chat agent +add_agent_framework_fastapi_endpoint( + app=app, + agent=simple_agent, + path="/agentic_chat", +) + +# Backend Tool Rendering - agent with tools +add_agent_framework_fastapi_endpoint( + app=app, + agent=weather_agent, + path="/backend_tool_rendering", +) + +# Shared State - recipe agent with structured output +add_agent_framework_fastapi_endpoint( + app=app, + agent=recipe_agent, + path="/shared_state", +) + +# Predictive State Updates - document writer with predictive state +add_agent_framework_fastapi_endpoint( + app=app, + agent=document_writer_agent, + path="/predictive_state_updates", +) + +# Human in the Loop - human-in-the-loop agent with step customization +add_agent_framework_fastapi_endpoint( + app=app, + agent=human_in_the_loop_agent, + path="/human_in_the_loop", + state_schema={"steps": {"type": "array"}}, + predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}}, +) + +# Agentic Generative UI - task steps agent with streaming state updates +add_agent_framework_fastapi_endpoint( + app=app, + agent=task_steps_agent, # type: ignore[arg-type] + path="/agentic_generative_ui", +) + +# Tool-based Generative UI - UI generator with frontend-rendered tools +add_agent_framework_fastapi_endpoint( + app=app, + agent=ui_generator_agent, + path="/tool_based_generative_ui", +) + + +def main(): + """Run the server.""" + port = int(os.getenv("PORT", "8888")) + host = os.getenv("HOST", "127.0.0.1") + + # Use log_config=None to prevent uvicorn from reconfiguring logging + # This preserves our file + console logging setup + uvicorn.run( + app, + host=host, + port=port, + log_config=None, + ) + + +if __name__ == "__main__": + main() diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md new file mode 100644 index 0000000000..2d1219bed4 --- /dev/null +++ b/python/packages/ag-ui/getting_started/README.md @@ -0,0 +1,705 @@ +# Getting Started with AG-UI (Python) + +The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python. + +## What is AG-UI? + +AG-UI is a protocol that enables: +- **Remote agent hosting**: Host AI agents as web services that can be accessed by multiple clients +- **Streaming responses**: Real-time streaming of agent responses using Server-Sent Events (SSE) +- **Standardized communication**: Consistent message format for agent interactions +- **Thread management**: Maintain conversation context across multiple requests +- **Advanced features**: Human-in-the-loop, state management, tool rendering + +## Prerequisites + +Before you begin, ensure you have the following: + +- Python 3.10 or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for DefaultAzureCredential) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource + +**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). + +**Note**: These samples use `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, or environment variables). For more information, see the [Azure Identity documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential). + +> **Warning** +> The AG-UI protocol is still under development and subject to change. +> We will keep these samples updated as the protocol evolves. + +## Step 1: Creating an AG-UI Server + +The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using FastAPI. + +### Install Required Packages + +```bash +pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +``` + +Or using uv: + +```bash +uv pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn +``` + +### Server Code + +Create a file named `server.py`: + +```python +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI server example.""" + +import os + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from fastapi import FastAPI + +# Read required configuration +endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") +deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") + +if not endpoint: + raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") +if not deployment_name: + raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") + +# Create the AI agent +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment_name, + ), +) + +# Create FastAPI app +app = FastAPI(title="AG-UI Server") + +# Register the AG-UI endpoint +add_agent_framework_fastapi_endpoint(app, agent, "/") + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=5100) +``` + +### Key Concepts + +- **`add_agent_framework_fastapi_endpoint`**: Registers the AG-UI endpoint with automatic request/response handling and SSE streaming +- **`ChatAgent`**: The agent that will handle incoming requests +- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses +- **Instructions**: The agent is created with default instructions, which can be overridden by client messages +- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly + +**Alternative (simpler)**: Use environment variables only: + +```python +# No need to read environment variables manually +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient(), # Reads from environment automatically +) +``` + +### Configure and Run the Server + +Set the required environment variables: + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +# Optional: Set API key if not using DefaultAzureCredential +# export AZURE_OPENAI_API_KEY="your-api-key" +``` + +Run the server: + +```bash +python server.py +``` + +Or using uvicorn directly: + +```bash +uvicorn server:app --host 127.0.0.1 --port 5100 +``` + +The server will start listening on `http://127.0.0.1:5100`. + +## Step 2: Creating an AG-UI Client + +The AG-UI client connects to the remote server and displays streaming responses. + +### Install Required Packages + +```bash +pip install httpx +``` + +### Client Code + +Create a file named `client.py`: + +```python +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI client example.""" + +import asyncio +import json +import os +from typing import AsyncIterator + +import httpx + + +class AGUIClient: + """Simple AG-UI protocol client.""" + + def __init__(self, server_url: str): + """Initialize the client. + + Args: + server_url: The AG-UI server endpoint URL + """ + self.server_url = server_url + self.thread_id: str | None = None + + async def send_message(self, message: str) -> AsyncIterator[dict]: + """Send a message and stream the response. + + Args: + message: The user message to send + + Yields: + AG-UI events from the server + """ + # Prepare the request + request_data = { + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": message}, + ] + } + + # Include thread_id if we have one (for conversation continuity) + if self.thread_id: + request_data["thread_id"] = self.thread_id + + # Stream the response + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + self.server_url, + json=request_data, + headers={"Accept": "text/event-stream"}, + ) as response: + response.raise_for_status() + + 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) + yield event + + # Capture thread_id from RUN_STARTED event + if event.get("type") == "RUN_STARTED" and not self.thread_id: + self.thread_id = event.get("threadId") + except json.JSONDecodeError: + continue + + +async def main(): + """Main client loop.""" + # Get server URL from environment or use default + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + print(f"Connecting to AG-UI server at: {server_url}\n") + + client = AGUIClient(server_url) + + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue + + if message.lower() in (":q", "quit"): + break + + # Send message and display streaming response + print("\n", end="") + async for event in client.send_message(message): + event_type = event.get("type", "") + + if event_type == "RUN_STARTED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "TEXT_MESSAGE_CONTENT": + # Stream text content in cyan + print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) + + elif event_type == "RUN_FINISHED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "RUN_ERROR": + error_message = event.get("message", "Unknown error") + print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") + + print() + + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\n\033[91mAn error occurred: {e}\033[0m") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Key Concepts + +- **Server-Sent Events (SSE)**: The protocol uses SSE format (`data: {json}\n\n`) +- **Event Types**: Different events provide metadata and content (all event types use UPPERCASE with underscores): + - `RUN_STARTED`: Signals the agent has started processing + - `TEXT_MESSAGE_START`: Signals the start of a text message from the agent + - `TEXT_MESSAGE_CONTENT`: Incremental text streamed from the agent (with `delta` field) + - `TEXT_MESSAGE_END`: Signals the end of a text message + - `RUN_FINISHED`: Signals successful completion + - `RUN_ERROR`: Error information if something goes wrong +- **Field Naming**: Event fields use camelCase (e.g., `threadId`, `runId`, `messageId`) when accessing JSON events +- **Thread Management**: The `threadId` maintains conversation context across requests +- **Client-Side Instructions**: System messages are sent from the client + +### Configure and Run the Client + +Optionally set a custom server URL: + +```bash +export AGUI_SERVER_URL="http://127.0.0.1:5100/" +``` + +Run the client (in a separate terminal): + +```bash +python client.py +``` + +## Step 3: Testing the Complete System + +### Expected Output + +``` +$ python client.py +Connecting to AG-UI server at: http://127.0.0.1:5100/ + +User (:q or quit to exit): What is the capital of France? + +[Run Started - Thread: abc123, Run: xyz789] +The capital of France is Paris. It is known for its rich history, culture, +and iconic landmarks such as the Eiffel Tower and the Louvre Museum. +[Run Finished - Thread: abc123, Run: xyz789] + +User (:q or quit to exit): Tell me a fun fact about space + +[Run Started - Thread: abc123, Run: def456] +Here's a fun fact: A day on Venus is longer than its year! Venus takes +about 243 Earth days to rotate once on its axis, but only about 225 Earth +days to orbit the Sun. +[Run Finished - Thread: abc123, Run: def456] + +User (:q or quit to exit): :q +``` + +### Color-Coded Output + +The client displays different content types with distinct colors: +- **Yellow**: Run started notifications +- **Cyan**: Agent text responses (streamed in real-time) +- **Green**: Run completion notifications +- **Red**: Error messages + +## Testing with curl (Optional) + +Before running the client, you can test the server manually using curl: + +```bash +curl -N http://127.0.0.1:5100/ \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "messages": [ + {"role": "user", "content": "What is the capital of France?"} + ] + }' +``` + +You should see Server-Sent Events streaming back: + +``` +data: {"type":"RUN_STARTED","threadId":"...","runId":"..."} + +data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"The"} + +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" capital"} + +... + +data: {"type":"TEXT_MESSAGE_END","messageId":"..."} + +data: {"type":"RUN_FINISHED","threadId":"...","runId":"..."} +``` + +## How It Works + +### Server-Side Flow + +1. Client sends HTTP POST request with messages +2. FastAPI endpoint receives the request +3. `AgentFrameworkAgent` wrapper orchestrates the execution +4. Agent processes the messages using Agent Framework +5. `AgentFrameworkEventBridge` converts agent updates to AG-UI events +6. Responses are streamed back as Server-Sent Events (SSE) +7. Connection closes when the run completes + +### Client-Side Flow + +1. Client sends HTTP POST request to server endpoint +2. Server responds with SSE stream +3. Client parses incoming `data:` lines as JSON events +4. Each event is displayed based on its type +5. `threadId` is captured for conversation continuity +6. Stream completes when `RUN_FINISHED` event arrives + +### Protocol Details + +The AG-UI protocol uses: +- **HTTP POST** for sending requests +- **Server-Sent Events (SSE)** for streaming responses +- **JSON** for event serialization +- **Thread IDs** for maintaining conversation context +- **Run IDs** for tracking individual executions +- **Event type naming**: UPPERCASE with underscores (e.g., `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`) +- **Field naming**: camelCase (e.g., `threadId`, `runId`, `messageId`) + +## Advanced Features + +The Python AG-UI implementation supports all 7 AG-UI features: + +### 1. Backend Tool Rendering + +Add tools to your agent for backend execution: + +```python +from typing import Any + +from agent_framework import ChatAgent, ai_function +from agent_framework.azure import AzureOpenAIChatClient + + +@ai_function +def get_weather(location: str) -> dict[str, Any]: + """Get weather for a location.""" + return {"temperature": 72, "conditions": "sunny"} + + +agent = ChatAgent( + name="weather_agent", + instructions="Use tools to help users.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), + tools=[get_weather], +) +``` + +The client will receive `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, and `TOOL_CALL_RESULT` events. + +### 2. Human in the Loop + +Request user confirmation before executing tools: + +```python +from fastapi import FastAPI +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint + +agent = ChatAgent( + name="my_agent", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint="https://your-resource.openai.azure.com/", + deployment_name="gpt-4o-mini", + ), +) + +wrapped_agent = AgentFrameworkAgent( + agent=agent, + require_confirmation=True, # Enable human-in-the-loop +) + +app = FastAPI() +add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/") +``` + +The client receives tool approval request events and can send approval responses. + +### 3. State Management + +Share state between client and server: + +```python +wrapped_agent = AgentFrameworkAgent( + agent=agent, + state_schema={ + "location": {"type": "string"}, + "preferences": {"type": "object"}, + }, +) +``` + +Events include `STATE_SNAPSHOT` and `STATE_DELTA` for bidirectional sync. + +### 4. Predictive State Updates + +Stream tool arguments as optimistic state updates: + +```python +wrapped_agent = AgentFrameworkAgent( + agent=agent, + predict_state_config={ + "location": {"tool": "get_weather", "tool_argument": "location"} + }, + require_confirmation=False, # Auto-update without confirmation +) +``` + +State updates stream in real-time as the LLM generates tool arguments. + +## Common Patterns + +### Custom Server Configuration + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI() + +# Add CORS for web clients +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +add_agent_framework_fastapi_endpoint(app, agent, "/agent") +``` + +### Multiple Agents + +```python +app = FastAPI() + +weather_agent = ChatAgent(name="weather", ...) +finance_agent = ChatAgent(name="finance", ...) + +add_agent_framework_fastapi_endpoint(app, weather_agent, "/weather") +add_agent_framework_fastapi_endpoint(app, finance_agent, "/finance") +``` + +### Custom Client Timeout + +```python +async with httpx.AsyncClient(timeout=300.0) as client: + async with client.stream("POST", server_url, ...) as response: + async for line in response.aiter_lines(): + # Process events + pass +``` + +### Error Handling + +```python +try: + async for event in client.send_message(message): + if event.get("type") == "RUN_ERROR": + error_msg = event.get("message", "Unknown error") + print(f"Error: {error_msg}") + # Handle error appropriately +except httpx.HTTPError as e: + print(f"HTTP error: {e}") +except Exception as e: + print(f"Unexpected error: {e}") +``` + +### Conversation Continuity + +The client automatically maintains `threadId` across requests: + +```python +client = AGUIClient(server_url) + +# First message +async for event in client.send_message("Hello"): + # Client captures threadId from RUN_STARTED + pass + +# Second message - uses same threadId +async for event in client.send_message("Continue our conversation"): + # Conversation context is maintained + pass +``` + +## AG-UI Event Reference + +### Core Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `RUN_STARTED` | Agent execution started | `threadId`, `runId` | +| `RUN_FINISHED` | Agent execution completed | `threadId`, `runId` | +| `RUN_ERROR` | Agent execution error | `message` | + +### Text Message Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `TEXT_MESSAGE_START` | Start of agent text message | `messageId`, `role` | +| `TEXT_MESSAGE_CONTENT` | Streaming text content | `messageId`, `delta` | +| `TEXT_MESSAGE_END` | End of agent text message | `messageId` | + +### Tool Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `TOOL_CALL_START` | Tool call initiated | `toolCallId`, `toolCallName` | +| `TOOL_CALL_ARGS` | Tool arguments streaming | `toolCallId`, `delta` | +| `TOOL_CALL_END` | Tool call complete | `toolCallId` | +| `TOOL_CALL_RESULT` | Tool execution result | `toolCallId`, `content` | + +### State Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `STATE_SNAPSHOT` | Complete state | `snapshot` | +| `STATE_DELTA` | State changes (JSON Patch) | `delta` | + +### Other Events + +| Event Type | Description | Key Fields | +|------------|-------------|------------| +| `MESSAGES_SNAPSHOT` | Conversation history | `messages` | +| `CUSTOM` | Custom event data | `name`, `value` | + +## Next Steps + +Now that you understand the basics of AG-UI, you can: + +- **Add Tools**: Create custom `@ai_function` tools for your domain +- **Web Integration**: Build React/Vue frontends using the AG-UI protocol +- **State Management**: Implement shared state for generative UI applications +- **Human-in-the-Loop**: Add approval workflows for sensitive operations +- **Deployment**: Deploy to Azure Container Apps or Azure App Service +- **Multi-Agent Systems**: Coordinate multiple specialized agents +- **Monitoring**: Add logging and OpenTelemetry for observability + +## Additional Resources + +- [AG-UI Examples](../examples/README.md): Complete working examples for all 7 features +- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents +- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation + +## Troubleshooting + +### Connection Refused + +Ensure the server is running before starting the client: + +```bash +# Terminal 1 +python server.py + +# Terminal 2 (after server starts) +python client.py +``` + +### Authentication Errors + +Make sure you're authenticated with Azure: + +```bash +az login +``` + +Verify you have the correct role assignment on the Azure OpenAI resource. + +### Streaming Not Working + +Check that your client timeout is sufficient: + +```python +httpx.AsyncClient(timeout=60.0) # 60 seconds should be enough +``` + +For long-running agents, increase the timeout accordingly. + +### No Events Received + +Ensure you're using the correct `Accept` header: + +```python +headers={"Accept": "text/event-stream"} +``` + +And parsing SSE format correctly (lines starting with `data: `). + +### Thread Context Lost + +The client automatically manages thread continuity. If context is lost: + +1. Check that `threadId` is being captured from `RUN_STARTED` events +2. Ensure the same client instance is used across messages +3. Verify the server is receiving the `thread_id` in subsequent requests + +### Event Type Mismatches + +Remember that event types are UPPERCASE with underscores (`RUN_STARTED`, not `run_started`) and field names are camelCase (`threadId`, not `thread_id`). + +### Import Errors + +Make sure all packages are installed: + +```bash +pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn httpx +``` + +Or check your virtual environment is activated: + +```bash +source venv/bin/activate # Linux/macOS +venv\Scripts\activate # Windows +``` diff --git a/python/packages/ag-ui/getting_started/client.py b/python/packages/ag-ui/getting_started/client.py new file mode 100644 index 0000000000..82d3d1358e --- /dev/null +++ b/python/packages/ag-ui/getting_started/client.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI client example.""" + +import asyncio +import json +import os +from collections.abc import AsyncIterator + +import httpx + + +class AGUIClient: + """Simple AG-UI protocol client.""" + + def __init__(self, server_url: str): + """Initialize the client. + + Args: + server_url: The AG-UI server endpoint URL + """ + self.server_url = server_url + self.thread_id: str | None = None + + async def send_message(self, message: str) -> AsyncIterator[dict]: + """Send a message and stream the response. + + Args: + message: The user message to send + + Yields: + AG-UI events from the server + """ + # Prepare the request + request_data: dict[str, object] = { + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": message}, + ] + } + + # Include thread_id if we have one (for conversation continuity) + if self.thread_id: + request_data["thread_id"] = self.thread_id + + # Stream the response + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + self.server_url, + json=request_data, + headers={"Accept": "text/event-stream"}, + ) as response: + response.raise_for_status() + + 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) + yield event + + # Capture thread_id from RUN_STARTED event + if event.get("type") == "RUN_STARTED" and not self.thread_id: + self.thread_id = event.get("threadId") + except json.JSONDecodeError: + continue + + +async def main(): + """Main client loop.""" + # Get server URL from environment or use default + server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") + print(f"Connecting to AG-UI server at: {server_url}\n") + + client = AGUIClient(server_url) + + try: + while True: + # Get user input + message = input("\nUser (:q or quit to exit): ") + if not message.strip(): + print("Request cannot be empty.") + continue + + if message.lower() in (":q", "quit"): + break + + # Send message and display streaming response + print("\n", end="") + async for event in client.send_message(message): + event_type = event.get("type", "") + + if event_type == "RUN_STARTED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "TEXT_MESSAGE_CONTENT": + # Stream text content in cyan + print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True) + + elif event_type == "RUN_FINISHED": + thread_id = event.get("threadId", "") + run_id = event.get("runId", "") + print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m") + + elif event_type == "RUN_ERROR": + error_message = event.get("message", "Unknown error") + print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m") + + print() + + except KeyboardInterrupt: + print("\n\nExiting...") + except Exception as e: + print(f"\n\033[91mAn error occurred: {e}\033[0m") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py new file mode 100644 index 0000000000..34e2edbd5f --- /dev/null +++ b/python/packages/ag-ui/getting_started/server.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI server example.""" + +import os + +from agent_framework import ChatAgent +from agent_framework.azure import AzureOpenAIChatClient +from dotenv import load_dotenv +from fastapi import FastAPI + +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint + +load_dotenv() + +# Read required configuration +endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") +deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") + +if not endpoint: + raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") +if not deployment_name: + raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required") + +# Create the AI agent +agent = ChatAgent( + name="AGUIAssistant", + instructions="You are a helpful assistant.", + chat_client=AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment_name, + ), +) + +# Create FastAPI app +app = FastAPI(title="AG-UI Server") + +# Register the AG-UI endpoint +add_agent_framework_fastapi_endpoint(app, agent, "/") + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=5100) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml new file mode 100644 index 0000000000..019d4705f2 --- /dev/null +++ b/python/packages/ag-ui/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "agent-framework-ag-ui" +version = "0.1.0" +description = "AG-UI protocol integration for Agent Framework" +readme = "README.md" +license = { file = "LICENSE" } +authors = [ + { name = "Microsoft", email = "agent-framework@microsoft.com" } +] +requires-python = ">=3.10" +dependencies = [ + "agent-framework-core", + "ag-ui-protocol>=0.1.9", + "fastapi>=0.115.0", + "uvicorn>=0.30.0" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["agent_framework_ag_ui"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pyright] +exclude = ["tests", "examples"] +typeCheckingMode = "basic" + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui" +test = "pytest --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/ag-ui/tests/__init__.py b/python/packages/ag-ui/tests/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/ag-ui/tests/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py new file mode 100644 index 0000000000..723e369c43 --- /dev/null +++ b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py @@ -0,0 +1,577 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for AgentFrameworkAgent (_agent.py).""" + +import json + +import pytest +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate + + +async def test_agent_initialization_basic(): + """Test basic agent initialization without state schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + assert wrapper.name == "test_agent" + assert wrapper.agent == agent + assert wrapper.config.state_schema == {} + assert wrapper.config.predict_state_config == {} + + +async def test_agent_initialization_with_state_schema(): + """Test agent initialization with state_schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + assert wrapper.config.state_schema == state_schema + + +async def test_agent_initialization_with_predict_state_config(): + """Test agent initialization with predict_state_config.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} + wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) + + assert wrapper.config.predict_state_config == predict_config + + +async def test_run_started_event_emission(): + """Test RunStartedEvent is emitted at start of run.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # First event should be RunStartedEvent + assert events[0].type == "RUN_STARTED" + assert events[0].run_id is not None + assert events[0].thread_id is not None + + +async def test_predict_state_custom_event_emission(): + """Test PredictState CustomEvent is emitted when predict_state_config is present.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + predict_config = { + "document": {"tool": "write_doc", "tool_argument": "content"}, + "summary": {"tool": "summarize", "tool_argument": "text"}, + } + wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find PredictState event + predict_events = [e for e in events if e.type == "CUSTOM" and e.name == "PredictState"] + assert len(predict_events) == 1 + + predict_value = predict_events[0].value + assert len(predict_value) == 2 + assert {"state_key": "document", "tool": "write_doc", "tool_argument": "content"} in predict_value + assert {"state_key": "summary", "tool": "summarize", "tool_argument": "text"} in predict_value + + +async def test_initial_state_snapshot_with_schema(): + """Test initial StateSnapshotEvent emission when state_schema present.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "state": {"document": "Initial content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # First snapshot should have initial state + assert snapshot_events[0].snapshot == {"document": "Initial content"} + + +async def test_state_initialization_object_type(): + """Test state initialization with object type in schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"recipe": {"type": "object", "properties": {}}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Should initialize as empty object + assert snapshot_events[0].snapshot == {"recipe": {}} + + +async def test_state_initialization_array_type(): + """Test state initialization with array type in schema.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + state_schema = {"steps": {"type": "array", "items": {}}} + wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Find StateSnapshotEvent + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Should initialize as empty array + assert snapshot_events[0].snapshot == {"steps": []} + + +async def test_run_finished_event_emission(): + """Test RunFinishedEvent is emitted at end of run.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Last event should be RunFinishedEvent + assert events[-1].type == "RUN_FINISHED" + + +async def test_tool_result_confirm_changes_accepted(): + """Test confirm_changes tool result handling when accepted.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Document updated")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + ) + + # Simulate tool result message with acceptance + tool_result = {"accepted": True, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", # Tool result from UI + "content": json.dumps(tool_result), + "toolCallId": "confirm_call_123", + } + ], + "state": {"document": "Updated content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text message confirming acceptance + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + # Should contain confirmation message mentioning the state key or generic confirmation + confirmation_found = any( + "document" in e.delta.lower() + or "confirm" in e.delta.lower() + or "applied" in e.delta.lower() + or "changes" in e.delta.lower() + for e in text_content_events + ) + assert confirmation_found, f"No confirmation in deltas: {[e.delta for e in text_content_events]}" + + +async def test_tool_result_confirm_changes_rejected(): + """Test confirm_changes tool result handling when rejected.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result message with rejection + tool_result = {"accepted": False, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "confirm_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text message asking what to change + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + assert any("what would you like me to change" in e.delta.lower() for e in text_content_events) + + +async def test_tool_result_function_approval_accepted(): + """Test function approval tool result when steps are accepted.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result with multiple steps + tool_result = { + "accepted": True, + "steps": [ + {"id": "step1", "description": "Send email", "status": "enabled"}, + {"id": "step2", "description": "Create calendar event", "status": "enabled"}, + ], + } + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "approval_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should list enabled steps + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + + # Concatenate all text content + full_text = "".join(e.delta for e in text_content_events) + assert "executing" in full_text.lower() + assert "2 approved steps" in full_text.lower() + assert "send email" in full_text.lower() + assert "create calendar event" in full_text.lower() + + +async def test_tool_result_function_approval_rejected(): + """Test function approval tool result when rejected.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="OK")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Simulate tool result rejection with steps + tool_result = { + "accepted": False, + "steps": [{"id": "step1", "description": "Send email", "status": "disabled"}], + } + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "approval_call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should ask what to change about the plan + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) > 0 + assert any("what would you like me to change about the plan" in e.delta.lower() for e in text_content_events) + + +async def test_thread_metadata_tracking(): + """Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + thread_metadata = {} + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Capture thread metadata from kwargs + nonlocal thread_metadata + if "thread" in kwargs: + thread_metadata = kwargs["thread"].metadata + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "thread_id": "test_thread_123", + "run_id": "test_run_456", + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Check thread metadata was set + # Note: This test may need adjustment based on actual thread passing mechanism + + +async def test_state_context_injection(): + """Test that current state is injected into thread metadata.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + thread_metadata = {} + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Track if state context message was added + nonlocal thread_metadata + # In actual implementation, thread is passed and state is in metadata + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + ) + + input_data = { + "messages": [{"role": "user", "content": "Hi"}], + "state": {"document": "Test content"}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # State should be injected - this is validated by agent execution flow + + +async def test_no_messages_provided(): + """Test handling when no messages are provided.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": []} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit RunStartedEvent and RunFinishedEvent only + assert len(events) == 2 + assert events[0].type == "RUN_STARTED" + assert events[-1].type == "RUN_FINISHED" + + +async def test_message_end_event_emission(): + """Test TextMessageEndEvent is emitted for assistant messages.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello world")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should have TextMessageEndEvent before RunFinishedEvent + end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"] + assert len(end_events) == 1 + + # EndEvent should come before FinishedEvent + end_index = events.index(end_events[0]) + finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0]) + assert end_index < finished_index + + +async def test_error_handling_with_exception(): + """Test that exceptions during agent execution are re-raised.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class FailingChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + if False: + yield + raise RuntimeError("Simulated failure") + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=FailingChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + with pytest.raises(RuntimeError, match="Simulated failure"): + async for event in wrapper.run_agent(input_data): + pass + + +async def test_json_decode_error_in_tool_result(): + """Test handling of JSONDecodeError when parsing tool result.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Fallback response")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent(agent=agent) + + # Send invalid JSON as tool result + input_data = { + "messages": [ + { + "role": "tool", + "content": "invalid json {not valid}", + "toolCallId": "call_123", + } + ], + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should fall through to normal agent processing + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + assert text_events[0].delta == "Fallback response" + + +async def test_suppressed_summary_with_document_state(): + """Test suppressed summary uses document state for confirmation message.""" + from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Response")]) + + agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"document": {"type": "string"}}, + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + confirmation_strategy=DocumentWriterConfirmationStrategy(), + ) + + # Simulate confirmation with document state + tool_result = {"accepted": True, "steps": []} + input_data = { + "messages": [ + { + "role": "tool", + "content": json.dumps(tool_result), + "toolCallId": "confirm_123", + } + ], + "state": {"document": "This is the beginning of a document. It contains important information."}, + } + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should generate fallback summary from document state + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + # Should contain some reference to the document + full_text = "".join(e.delta for e in text_events) + assert "written" in full_text.lower() or "document" in full_text.lower() diff --git a/python/packages/ag-ui/tests/test_backend_tool_rendering.py b/python/packages/ag-ui/tests/test_backend_tool_rendering.py new file mode 100644 index 0000000000..fbd27ee8bb --- /dev/null +++ b/python/packages/ag-ui/tests/test_backend_tool_rendering.py @@ -0,0 +1,124 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for backend tool rendering.""" + +from ag_ui.core import ( + TextMessageContentEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from agent_framework import AgentRunResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_tool_call_flow(): + """Test complete tool call flow: call -> args -> end -> result.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Step 1: Tool call starts + tool_call = FunctionCallContent( + call_id="weather-123", + name="get_weather", + arguments={"location": "Seattle"}, + ) + + update1 = AgentRunResponseUpdate(contents=[tool_call]) + events1 = await bridge.from_agent_run_update(update1) + + # Should have: ToolCallStartEvent, ToolCallArgsEvent + assert len(events1) == 2 + assert isinstance(events1[0], ToolCallStartEvent) + assert isinstance(events1[1], ToolCallArgsEvent) + + start_event = events1[0] + assert start_event.tool_call_id == "weather-123" + assert start_event.tool_call_name == "get_weather" + + args_event = events1[1] + assert "Seattle" in args_event.delta + + # Step 2: Tool result comes back + tool_result = FunctionResultContent( + call_id="weather-123", + result="Weather in Seattle: Rainy, 52°F", + ) + + update2 = AgentRunResponseUpdate(contents=[tool_result]) + events2 = await bridge.from_agent_run_update(update2) + + # Should have: ToolCallEndEvent, ToolCallResultEvent, MessagesSnapshotEvent + assert len(events2) == 3 + assert isinstance(events2[0], ToolCallEndEvent) + assert isinstance(events2[1], ToolCallResultEvent) + + end_event = events2[0] + assert end_event.tool_call_id == "weather-123" + + result_event = events2[1] + assert result_event.tool_call_id == "weather-123" + assert "Seattle" in result_event.content + assert "Rainy" in result_event.content + + +async def test_text_with_tool_call(): + """Test agent response with both text and tool calls.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Agent says something then calls a tool + text_content = TextContent(text="Let me check the weather for you.") + tool_call = FunctionCallContent( + call_id="weather-456", + name="get_forecast", + arguments={"location": "San Francisco", "days": 3}, + ) + + update = AgentRunResponseUpdate(contents=[text_content, tool_call]) + events = await bridge.from_agent_run_update(update) + + # Should have: TextMessageStart, TextMessageContent, ToolCallStart, ToolCallArgs + assert len(events) == 4 + + assert isinstance(events[0], TextMessageStartEvent) + assert isinstance(events[1], TextMessageContentEvent) + assert isinstance(events[2], ToolCallStartEvent) + assert isinstance(events[3], ToolCallArgsEvent) + + text_event = events[1] + assert "check the weather" in text_event.delta + + tool_start = events[2] + assert tool_start.tool_call_name == "get_forecast" + + +async def test_multiple_tool_results(): + """Test handling multiple tool results in sequence.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # Multiple tool results + results = [ + FunctionResultContent(call_id="tool-1", result="Result 1"), + FunctionResultContent(call_id="tool-2", result="Result 2"), + FunctionResultContent(call_id="tool-3", result="Result 3"), + ] + + update = AgentRunResponseUpdate(contents=results) + events = await bridge.from_agent_run_update(update) + + # Should have 3 pairs of ToolCallEndEvent + ToolCallResultEvent = 6 events + assert len(events) == 6 + + # Verify the pattern: End, Result, End, Result, End, Result + for i in range(3): + end_idx = i * 2 + result_idx = i * 2 + 1 + + assert isinstance(events[end_idx], ToolCallEndEvent) + assert isinstance(events[result_idx], ToolCallResultEvent) + + assert events[end_idx].tool_call_id == f"tool-{i + 1}" + assert events[result_idx].tool_call_id == f"tool-{i + 1}" + assert f"Result {i + 1}" in events[result_idx].content diff --git a/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py new file mode 100644 index 0000000000..205182d58d --- /dev/null +++ b/python/packages/ag-ui/tests/test_confirmation_strategies_comprehensive.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for all confirmation strategies.""" + +import pytest + +from agent_framework_ag_ui._confirmation_strategies import ( + ConfirmationStrategy, + DefaultConfirmationStrategy, + DocumentWriterConfirmationStrategy, + RecipeConfirmationStrategy, + TaskPlannerConfirmationStrategy, +) + + +@pytest.fixture +def sample_steps(): + """Sample steps for testing approval messages.""" + return [ + {"description": "Step 1: Do something", "status": "enabled"}, + {"description": "Step 2: Do another thing", "status": "enabled"}, + {"description": "Step 3: Disabled step", "status": "disabled"}, + ] + + +@pytest.fixture +def all_enabled_steps(): + """All steps enabled.""" + return [ + {"description": "Task A", "status": "enabled"}, + {"description": "Task B", "status": "enabled"}, + {"description": "Task C", "status": "enabled"}, + ] + + +@pytest.fixture +def empty_steps(): + """Empty steps list.""" + return [] + + +class TestDefaultConfirmationStrategy: + """Tests for DefaultConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Executing 2 approved steps" in message + assert "Step 1: Do something" in message + assert "Step 2: Do another thing" in message + assert "Step 3" not in message # Disabled step shouldn't appear + assert "All steps completed successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Executing 3 approved steps" in message + assert "Task A" in message + assert "Task B" in message + assert "Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Executing 0 approved steps" in message + assert "All steps completed successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = DefaultConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "What would you like me to change" in message + + def test_on_state_confirmed(self): + strategy = DefaultConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Changes confirmed" in message + assert "successfully" in message + + def test_on_state_rejected(self): + strategy = DefaultConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "What would you like me to change" in message + + +class TestTaskPlannerConfirmationStrategy: + """Tests for TaskPlannerConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Executing your requested tasks" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "All tasks completed successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Executing your requested tasks" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Executing your requested tasks" in message + assert "All tasks completed successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "revise the plan" in message + + def test_on_state_confirmed(self): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Tasks confirmed" in message + assert "ready to execute" in message + + def test_on_state_rejected(self): + strategy = TaskPlannerConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "adjust the task list" in message + + +class TestRecipeConfirmationStrategy: + """Tests for RecipeConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Updating your recipe" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "Recipe updated successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Updating your recipe" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Updating your recipe" in message + assert "Recipe updated successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = RecipeConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "ingredients or steps" in message + + def test_on_state_confirmed(self): + strategy = RecipeConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Recipe changes applied" in message + assert "successfully" in message + + def test_on_state_rejected(self): + strategy = RecipeConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "adjust in the recipe" in message + + +class TestDocumentWriterConfirmationStrategy: + """Tests for DocumentWriterConfirmationStrategy.""" + + def test_on_approval_accepted_with_enabled_steps(self, sample_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(sample_steps) + + assert "Applying your edits" in message + assert "1. Step 1: Do something" in message + assert "2. Step 2: Do another thing" in message + assert "Step 3" not in message + assert "Document updated successfully!" in message + + def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(all_enabled_steps) + + assert "Applying your edits" in message + assert "1. Task A" in message + assert "2. Task B" in message + assert "3. Task C" in message + + def test_on_approval_accepted_with_empty_steps(self, empty_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_accepted(empty_steps) + + assert "Applying your edits" in message + assert "Document updated successfully!" in message + + def test_on_approval_rejected(self, sample_steps): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_approval_rejected(sample_steps) + + assert "No problem!" in message + assert "keep or modify" in message + + def test_on_state_confirmed(self): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_state_confirmed() + + assert "Document edits applied!" in message + + def test_on_state_rejected(self): + strategy = DocumentWriterConfirmationStrategy() + message = strategy.on_state_rejected() + + assert "No problem!" in message + assert "change about the document" in message + + +class TestConfirmationStrategyInterface: + """Tests for ConfirmationStrategy abstract base class.""" + + def test_cannot_instantiate_abstract_class(self): + """Verify ConfirmationStrategy is abstract and cannot be instantiated.""" + with pytest.raises(TypeError): + ConfirmationStrategy() # type: ignore + + def test_all_strategies_implement_interface(self): + """Verify all concrete strategies implement the full interface.""" + strategies = [ + DefaultConfirmationStrategy(), + TaskPlannerConfirmationStrategy(), + RecipeConfirmationStrategy(), + DocumentWriterConfirmationStrategy(), + ] + + sample_steps = [{"description": "Test", "status": "enabled"}] + + for strategy in strategies: + # All should have these methods + assert callable(strategy.on_approval_accepted) + assert callable(strategy.on_approval_rejected) + assert callable(strategy.on_state_confirmed) + assert callable(strategy.on_state_rejected) + + # All should return strings + assert isinstance(strategy.on_approval_accepted(sample_steps), str) + assert isinstance(strategy.on_approval_rejected(sample_steps), str) + assert isinstance(strategy.on_state_confirmed(), str) + assert isinstance(strategy.on_state_rejected(), str) diff --git a/python/packages/ag-ui/tests/test_document_writer_flow.py b/python/packages/ag-ui/tests/test_document_writer_flow.py new file mode 100644 index 0000000000..d46b9bf7a0 --- /dev/null +++ b/python/packages/ag-ui/tests/test_document_writer_flow.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for document writer predictive state flow with confirm_changes.""" + +from ag_ui.core import EventType +from agent_framework import FunctionCallContent, FunctionResultContent, TextContent +from agent_framework._types import AgentRunResponseUpdate + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_streaming_document_with_state_deltas(): + """Test that streaming tool arguments emit progressive StateDeltaEvents.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Simulate streaming tool call - first chunk with name + tool_call_start = FunctionCallContent( + call_id="call_123", + name="write_document_local", + arguments='{"document":"Once', + ) + update1 = AgentRunResponseUpdate(contents=[tool_call_start]) + events1 = await bridge.from_agent_run_update(update1) + + # Should have ToolCallStartEvent and ToolCallArgsEvent + assert any(e.type == EventType.TOOL_CALL_START for e in events1) + assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1) + + # Second chunk - incomplete JSON, should try partial extraction + tool_call_chunk2 = FunctionCallContent( + call_id="call_123", + name=None, # Name only in first chunk + arguments=" upon a time", + ) + update2 = AgentRunResponseUpdate(contents=[tool_call_chunk2]) + events2 = await bridge.from_agent_run_update(update2) + + # Should emit StateDeltaEvent with partial document + state_deltas = [e for e in events2 if e.type == EventType.STATE_DELTA] + assert len(state_deltas) >= 1 + + # Check JSON Patch format + delta = state_deltas[0] + assert isinstance(delta.delta, list) + assert len(delta.delta) > 0 + assert delta.delta[0]["op"] == "replace" + assert delta.delta[0]["path"] == "/document" + assert "Once upon a time" in delta.delta[0]["value"] + + +async def test_confirm_changes_emission(): + """Test that confirm_changes tool call is emitted after predictive tool completion.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + current_state = {} + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + current_state=current_state, + ) + + # Set current tool name (simulating earlier tool call start) + bridge.current_tool_call_name = "write_document_local" + bridge.pending_state_updates = {"document": "A short story"} + + # Tool result + tool_result = FunctionResultContent( + call_id="call_123", + result="Document written.", + ) + + update = AgentRunResponseUpdate(contents=[tool_result]) + events = await bridge.from_agent_run_update(update) + + # Should have: ToolCallEndEvent, ToolCallResultEvent, StateSnapshotEvent, confirm_changes sequence + assert any(e.type == EventType.TOOL_CALL_END for e in events) + assert any(e.type == EventType.TOOL_CALL_RESULT for e in events) + assert any(e.type == EventType.STATE_SNAPSHOT for e in events) + + # Check for confirm_changes tool call + confirm_starts = [ + e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" + ] + assert len(confirm_starts) == 1 + + confirm_args = [e for e in events if e.type == EventType.TOOL_CALL_ARGS and e.delta == "{}"] + assert len(confirm_args) >= 1 + + confirm_ends = [e for e in events if e.type == EventType.TOOL_CALL_END] + # At least 2: one for write_document_local, one for confirm_changes + assert len(confirm_ends) >= 2 + + # Check that stop flag is set + assert bridge.should_stop_after_confirm is True + + +async def test_text_suppression_before_confirm(): + """Test that text messages are suppressed when confirm_changes is pending.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Set flag indicating we're waiting for confirmation + bridge.should_stop_after_confirm = True + + # Text content that should be suppressed + text = TextContent(text="I have written a story about pirates.") + update = AgentRunResponseUpdate(contents=[text]) + + events = await bridge.from_agent_run_update(update) + + # Should NOT emit TextMessageContentEvent + text_events = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT] + assert len(text_events) == 0 + + # But should save the text + assert bridge.suppressed_summary == "I have written a story about pirates." + + +async def test_no_confirm_for_non_predictive_tools(): + """Test that confirm_changes is NOT emitted for regular tool calls.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + current_state = {} + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + current_state=current_state, + ) + + # Different tool (not in predict_state_config) + bridge.current_tool_call_name = "get_weather" + + tool_result = FunctionResultContent( + call_id="call_456", + result="Sunny, 72°F", + ) + + update = AgentRunResponseUpdate(contents=[tool_result]) + events = await bridge.from_agent_run_update(update) + + # Should NOT have confirm_changes + confirm_starts = [ + e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes" + ] + assert len(confirm_starts) == 0 + + # Stop flag should NOT be set + assert bridge.should_stop_after_confirm is False + + +async def test_state_delta_deduplication(): + """Test that duplicate state values don't emit multiple StateDeltaEvents.""" + predict_config = { + "document": {"tool": "write_document_local", "tool_argument": "document"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # First tool call with document + tool_call1 = FunctionCallContent( + call_id="call_1", + name="write_document_local", + arguments='{"document":"Same text"}', + ) + update1 = AgentRunResponseUpdate(contents=[tool_call1]) + events1 = await bridge.from_agent_run_update(update1) + + # Count state deltas + state_deltas_1 = [e for e in events1 if e.type == EventType.STATE_DELTA] + assert len(state_deltas_1) >= 1 + + # Second tool call with SAME document (shouldn't emit new delta) + bridge.current_tool_call_name = "write_document_local" + tool_call2 = FunctionCallContent( + call_id="call_2", + name=None, + arguments='{"document":"Same text"}', # Identical content + ) + update2 = AgentRunResponseUpdate(contents=[tool_call2]) + events2 = await bridge.from_agent_run_update(update2) + + # Should NOT emit state delta (same value) + state_deltas_2 = [e for e in events2 if e.type == EventType.STATE_DELTA] + assert len(state_deltas_2) == 0 + + +async def test_predict_state_config_multiple_fields(): + """Test predictive state with multiple state fields.""" + predict_config = { + "title": {"tool": "create_post", "tool_argument": "title"}, + "content": {"tool": "create_post", "tool_argument": "body"}, + } + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config=predict_config, + ) + + # Tool call with both fields + tool_call = FunctionCallContent( + call_id="call_999", + name="create_post", + arguments='{"title":"My Post","body":"Post content"}', + ) + update = AgentRunResponseUpdate(contents=[tool_call]) + events = await bridge.from_agent_run_update(update) + + # Should emit StateDeltaEvent for both fields + state_deltas = [e for e in events if e.type == EventType.STATE_DELTA] + assert len(state_deltas) >= 2 + + # Check both fields are present + paths = [delta.delta[0]["path"] for delta in state_deltas] + assert "/title" in paths + assert "/content" in paths diff --git a/python/packages/ag-ui/tests/test_endpoint.py b/python/packages/ag-ui/tests/test_endpoint.py new file mode 100644 index 0000000000..b5846bbbf8 --- /dev/null +++ b/python/packages/ag-ui/tests/test_endpoint.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for FastAPI endpoint creation (_endpoint.py).""" + +import json +from typing import Any + +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agent_framework_ag_ui._agent import AgentFrameworkAgent +from agent_framework_ag_ui._endpoint import add_agent_framework_fastapi_endpoint + + +class MockChatClient: + """Mock chat client for testing.""" + + def __init__(self, response_text: str = "Test response"): + self.response_text = response_text + + async def get_streaming_response(self, messages: list[Any], chat_options: Any, **kwargs: Any): + """Mock streaming response.""" + yield ChatResponseUpdate(contents=[TextContent(text=self.response_text)]) + + +async def test_add_endpoint_with_agent_protocol(): + """Test adding endpoint with raw AgentProtocol.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/test-agent") + + client = TestClient(app) + response = client.post("/test-agent", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + + +async def test_add_endpoint_with_wrapped_agent(): + """Test adding endpoint with pre-wrapped AgentFrameworkAgent.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + wrapped_agent = AgentFrameworkAgent(agent=agent, name="wrapped") + + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/wrapped-agent") + + client = TestClient(app) + response = client.post("/wrapped-agent", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + + +async def test_endpoint_with_state_schema(): + """Test endpoint with state_schema parameter.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + state_schema = {"document": {"type": "string"}} + + add_agent_framework_fastapi_endpoint(app, agent, path="/stateful", state_schema=state_schema) + + client = TestClient(app) + response = client.post( + "/stateful", json={"messages": [{"role": "user", "content": "Hello"}], "state": {"document": ""}} + ) + + assert response.status_code == 200 + + +async def test_endpoint_with_predict_state_config(): + """Test endpoint with predict_state_config parameter.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} + + add_agent_framework_fastapi_endpoint(app, agent, path="/predictive", predict_state_config=predict_config) + + client = TestClient(app) + response = client.post("/predictive", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + +async def test_endpoint_request_logging(): + """Test that endpoint logs request details.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/logged") + + client = TestClient(app) + response = client.post( + "/logged", + json={ + "messages": [{"role": "user", "content": "Test"}], + "run_id": "run-123", + "thread_id": "thread-456", + }, + ) + + assert response.status_code == 200 + + +async def test_endpoint_event_streaming(): + """Test that endpoint streams events correctly.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient("Streamed response")) + + add_agent_framework_fastapi_endpoint(app, agent, path="/stream") + + client = TestClient(app) + response = client.post("/stream", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + content = response.content.decode("utf-8") + lines = [line for line in content.split("\n") if line.strip()] + + found_run_started = False + found_text_content = False + found_run_finished = False + + for line in lines: + if line.startswith("data: "): + event_data = json.loads(line[6:]) + if event_data.get("type") == "RUN_STARTED": + found_run_started = True + elif event_data.get("type") == "TEXT_MESSAGE_CONTENT": + found_text_content = True + elif event_data.get("type") == "RUN_FINISHED": + found_run_finished = True + + assert found_run_started + assert found_text_content + assert found_run_finished + + +async def test_endpoint_error_handling(): + """Test endpoint error handling during request parsing.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/failing") + + client = TestClient(app) + + # Send invalid JSON to trigger parsing error before streaming + response = client.post("/failing", data="invalid json", headers={"content-type": "application/json"}) + + # The exception handler catches it and returns JSON error + assert response.status_code == 200 + content = json.loads(response.content) + assert "error" in content + assert "Expecting value" in content["error"] + + +async def test_endpoint_multiple_paths(): + """Test adding multiple endpoints with different paths.""" + app = FastAPI() + agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=MockChatClient("Response 1")) + agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=MockChatClient("Response 2")) + + add_agent_framework_fastapi_endpoint(app, agent1, path="/agent1") + add_agent_framework_fastapi_endpoint(app, agent2, path="/agent2") + + client = TestClient(app) + + response1 = client.post("/agent1", json={"messages": [{"role": "user", "content": "Hi"}]}) + response2 = client.post("/agent2", json={"messages": [{"role": "user", "content": "Hi"}]}) + + assert response1.status_code == 200 + assert response2.status_code == 200 + + +async def test_endpoint_default_path(): + """Test endpoint with default path.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent) + + client = TestClient(app) + response = client.post("/", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + + +async def test_endpoint_response_headers(): + """Test that endpoint sets correct response headers.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/headers") + + client = TestClient(app) + response = client.post("/headers", json={"messages": [{"role": "user", "content": "Test"}]}) + + assert response.status_code == 200 + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" + assert "cache-control" in response.headers + assert response.headers["cache-control"] == "no-cache" + + +async def test_endpoint_empty_messages(): + """Test endpoint with empty messages list.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/empty") + + client = TestClient(app) + response = client.post("/empty", json={"messages": []}) + + assert response.status_code == 200 + + +async def test_endpoint_complex_input(): + """Test endpoint with complex input data.""" + app = FastAPI() + agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient()) + + add_agent_framework_fastapi_endpoint(app, agent, path="/complex") + + client = TestClient(app) + response = client.post( + "/complex", + json={ + "messages": [ + {"role": "user", "content": "First message", "id": "msg-1"}, + {"role": "assistant", "content": "Response", "id": "msg-2"}, + {"role": "user", "content": "Follow-up", "id": "msg-3"}, + ], + "run_id": "complex-run-123", + "thread_id": "complex-thread-456", + "state": {"custom_field": "value"}, + }, + ) + + assert response.status_code == 200 diff --git a/python/packages/ag-ui/tests/test_events_comprehensive.py b/python/packages/ag-ui/tests/test_events_comprehensive.py new file mode 100644 index 0000000000..cd2663bd3c --- /dev/null +++ b/python/packages/ag-ui/tests/test_events_comprehensive.py @@ -0,0 +1,659 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for AgentFrameworkEventBridge (_events.py).""" + +import json + +from agent_framework import ( + AgentRunResponseUpdate, + FunctionApprovalRequestContent, + FunctionCallContent, + FunctionResultContent, + TextContent, +) + + +async def test_basic_text_message_conversion(): + """Test basic TextContent to AG-UI events.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[TextContent(text="Hello")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TEXT_MESSAGE_START" + assert events[0].role == "assistant" + assert events[1].type == "TEXT_MESSAGE_CONTENT" + assert events[1].delta == "Hello" + + +async def test_text_message_streaming(): + """Test streaming TextContent with multiple chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")]) + update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")]) + + events1 = await bridge.from_agent_run_update(update1) + events2 = await bridge.from_agent_run_update(update2) + + # First update: START + CONTENT + assert len(events1) == 2 + assert events1[0].type == "TEXT_MESSAGE_START" + assert events1[1].delta == "Hello " + + # Second update: just CONTENT (same message) + assert len(events2) == 1 + assert events2[0].type == "TEXT_MESSAGE_CONTENT" + assert events2[0].delta == "world" + + # Both content events should have same message_id + assert events1[1].message_id == events2[0].message_id + + +async def test_skip_text_content_for_structured_outputs(): + """Test that text content is skipped when skip_text_content=True.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True) + + update = AgentRunResponseUpdate(contents=[TextContent(text='{"result": "data"}')]) + events = await bridge.from_agent_run_update(update) + + # No events should be emitted + assert len(events) == 0 + + +async def test_tool_call_with_name(): + """Test FunctionCallContent with name emits ToolCallStartEvent.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 1 + assert events[0].type == "TOOL_CALL_START" + assert events[0].tool_call_name == "search_web" + assert events[0].tool_call_id == "call_123" + + +async def test_tool_call_streaming_args(): + """Test streaming tool call arguments.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk: name only + update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")]) + events1 = await bridge.from_agent_run_update(update1) + + # Second chunk: arguments chunk 1 (name can be empty string for continuation) + update2 = AgentRunResponseUpdate( + contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')] + ) + events2 = await bridge.from_agent_run_update(update2) + + # Third chunk: arguments chunk 2 + update3 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')]) + events3 = await bridge.from_agent_run_update(update3) + + # First update: ToolCallStartEvent + assert len(events1) == 1 + assert events1[0].type == "TOOL_CALL_START" + + # Second update: ToolCallArgsEvent + assert len(events2) == 1 + assert events2[0].type == "TOOL_CALL_ARGS" + assert events2[0].delta == '{"query": "' + + # Third update: ToolCallArgsEvent + assert len(events3) == 1 + assert events3[0].type == "TOOL_CALL_ARGS" + assert events3[0].delta == 'AI"}' + + # All should have same tool_call_id + assert events1[0].tool_call_id == events2[0].tool_call_id == events3[0].tool_call_id + + +async def test_tool_result_with_dict(): + """Test FunctionResultContent with dict result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + result_data = {"status": "success", "count": 42} + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + ToolCallResultEvent + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].tool_call_id == "call_123" + assert events[1].role == "tool" + # Result should be JSON-serialized + assert json.loads(events[1].content) == result_data + + +async def test_tool_result_with_string(): + """Test FunctionResultContent with string result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].content == "Search complete" + + +async def test_tool_result_with_none(): + """Test FunctionResultContent with None result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].content == "" + + +async def test_multiple_tool_results_in_sequence(): + """Test multiple tool results processed sequentially.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate( + contents=[ + FunctionResultContent(call_id="call_1", result="Result 1"), + FunctionResultContent(call_id="call_2", result="Result 2"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Each result emits: ToolCallEndEvent + ToolCallResultEvent = 4 events total + assert len(events) == 4 + assert events[0].tool_call_id == "call_1" + assert events[1].tool_call_id == "call_1" + assert events[2].tool_call_id == "call_2" + assert events[3].tool_call_id == "call_2" + + +async def test_function_approval_request_basic(): + """Test FunctionApprovalRequestContent conversion.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + func_call = FunctionCallContent( + call_id="call_123", + name="send_email", + arguments={"to": "user@example.com", "subject": "Test"}, + ) + approval = FunctionApprovalRequestContent( + id="approval_001", + function_call=func_call, + ) + + update = AgentRunResponseUpdate(contents=[approval]) + events = await bridge.from_agent_run_update(update) + + # Should emit: ToolCallEndEvent + CustomEvent + assert len(events) == 2 + + # First: ToolCallEndEvent to close the tool call + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + # Second: CustomEvent with approval details + assert events[1].type == "CUSTOM" + assert events[1].name == "function_approval_request" + assert events[1].value["id"] == "approval_001" + assert events[1].value["function_call"]["name"] == "send_email" + + +async def test_empty_predict_state_config(): + """Test behavior with no predictive state configuration.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={}, # Empty config + ) + + # Tool call with arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'), + FunctionResultContent(call_id="call_1", result="Done"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should NOT emit StateDeltaEvent or confirm_changes + event_types = [e.type for e in events] + assert "STATE_DELTA" not in event_types + assert "STATE_SNAPSHOT" not in event_types + + # Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult, MessagesSnapshot + # MessagesSnapshotEvent is emitted after tool results to track the conversation + assert event_types == [ + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "MESSAGES_SNAPSHOT", + ] + + +async def test_tool_not_in_predict_state_config(): + """Test tool that doesn't match any predict_state_config entry.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_document", "tool_argument": "content"}, + }, + ) + + # Different tool name + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'), + FunctionResultContent(call_id="call_1", result="Results"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should NOT emit StateDeltaEvent or confirm_changes + event_types = [e.type for e in events] + assert "STATE_DELTA" not in event_types + assert "STATE_SNAPSHOT" not in event_types + + +async def test_state_management_tracking(): + """Test current_state and pending_state_updates tracking.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + initial_state = {"document": ""} + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + current_state=initial_state, + ) + + # Streaming tool call + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + # Check pending_state_updates was populated + assert "document" in bridge.pending_state_updates + assert bridge.pending_state_updates["document"] == "Hello" + + # Tool result should update current_state + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + await bridge.from_agent_run_update(update2) + + # current_state should be updated + assert bridge.current_state["document"] == "Hello" + + # pending_state_updates should be cleared + assert len(bridge.pending_state_updates) == 0 + + +async def test_wildcard_tool_argument(): + """Test tool_argument='*' uses all arguments as state value.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "recipe": {"tool": "create_recipe", "tool_argument": "*"}, + }, + current_state={}, + ) + + # Complete tool call with dict arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent( + name="create_recipe", + call_id="call_1", + arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]}, + ), + FunctionResultContent(call_id="call_1", result="Created"), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Find StateDeltaEvent + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) > 0 + + # Value should be the entire arguments dict + delta = delta_events[0].delta[0] + assert delta["path"] == "/recipe" + assert delta["value"] == {"title": "Pasta", "ingredients": ["pasta", "sauce"]} + + +async def test_run_lifecycle_events(): + """Test RunStartedEvent and RunFinishedEvent creation.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + started = bridge.create_run_started_event() + assert started.type == "RUN_STARTED" + assert started.run_id == "test_run" + assert started.thread_id == "test_thread" + + finished = bridge.create_run_finished_event(result={"status": "complete"}) + assert finished.type == "RUN_FINISHED" + assert finished.run_id == "test_run" + assert finished.thread_id == "test_thread" + assert finished.result == {"status": "complete"} + + +async def test_message_lifecycle_events(): + """Test TextMessageStartEvent and TextMessageEndEvent creation.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + start = bridge.create_message_start_event("msg_123", role="assistant") + assert start.type == "TEXT_MESSAGE_START" + assert start.message_id == "msg_123" + assert start.role == "assistant" + + end = bridge.create_message_end_event("msg_123") + assert end.type == "TEXT_MESSAGE_END" + assert end.message_id == "msg_123" + + +async def test_state_event_creation(): + """Test StateSnapshotEvent and StateDeltaEvent creation helpers.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # StateSnapshotEvent + snapshot = bridge.create_state_snapshot_event({"document": "content"}) + assert snapshot.type == "STATE_SNAPSHOT" + assert snapshot.snapshot == {"document": "content"} + + # StateDeltaEvent with JSON Patch + delta = bridge.create_state_delta_event([{"op": "replace", "path": "/document", "value": "new content"}]) + assert delta.type == "STATE_DELTA" + assert len(delta.delta) == 1 + assert delta.delta[0]["op"] == "replace" + assert delta.delta[0]["path"] == "/document" + assert delta.delta[0]["value"] == "new content" + + +async def test_state_snapshot_after_tool_result(): + """Test StateSnapshotEvent emission after tool result with pending updates.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + current_state={"document": ""}, + ) + + # Tool call with streaming args + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + # Tool result should trigger StateSnapshotEvent + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + events = await bridge.from_agent_run_update(update2) + + # Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot["document"] == "Test" + + +async def test_message_id_persistence_across_chunks(): + """Test that message_id persists across multiple text chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk + update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")]) + events1 = await bridge.from_agent_run_update(update1) + message_id = events1[0].message_id + + # Second chunk + update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")]) + events2 = await bridge.from_agent_run_update(update2) + + # Should use same message_id + assert events2[0].message_id == message_id + assert bridge.current_message_id == message_id + + +async def test_tool_call_id_tracking(): + """Test tool_call_id tracking across streaming chunks.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # First chunk with name + update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")]) + await bridge.from_agent_run_update(update1) + + assert bridge.current_tool_call_id == "call_1" + assert bridge.current_tool_call_name == "search" + + # Second chunk with args but no name + update2 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')]) + events2 = await bridge.from_agent_run_update(update2) + + # Should still track same tool call + assert bridge.current_tool_call_id == "call_1" + assert events2[0].tool_call_id == "call_1" + + +async def test_tool_name_reset_after_result(): + """Test current_tool_call_name is reset after tool result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "document": {"tool": "write_doc", "tool_argument": "content"}, + }, + ) + + # Tool call + update1 = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="write_doc", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'), + ] + ) + await bridge.from_agent_run_update(update1) + + assert bridge.current_tool_call_name == "write_doc" + + # Tool result with predictive state (should trigger confirm_changes and reset) + update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]) + await bridge.from_agent_run_update(update2) + + # Tool name should be reset + assert bridge.current_tool_call_name is None + + +async def test_function_approval_with_wildcard_argument(): + """Test function approval with wildcard * argument.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "payload": {"tool": "submit", "tool_argument": "*"}, + }, + ) + + approval_content = FunctionApprovalRequestContent( + id="approval_1", + function_call=FunctionCallContent( + name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}' + ), + ) + + update = AgentRunResponseUpdate(contents=[approval_content]) + events = await bridge.from_agent_run_update(update) + + # Should emit StateSnapshotEvent with entire parsed args as value + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot["payload"] == {"key1": "value1", "key2": "value2"} + + +async def test_function_approval_missing_argument(): + """Test function approval when specified argument is not in parsed args.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={ + "data": {"tool": "process", "tool_argument": "missing_field"}, + }, + ) + + approval_content = FunctionApprovalRequestContent( + id="approval_1", + function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'), + ) + + update = AgentRunResponseUpdate(contents=[approval_content]) + events = await bridge.from_agent_run_update(update) + + # Should not emit StateSnapshotEvent since argument not found + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) == 0 + + +async def test_empty_predict_state_config_no_deltas(): + """Test with empty predict_state_config (no predictive updates).""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", predict_state_config={}) + + # Tool call with arguments + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should not emit any StateDeltaEvents + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) == 0 + + +async def test_tool_with_no_matching_config(): + """Test tool call for tool not in predict_state_config.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}, + ) + + # Tool call for different tool + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="search_web", call_id="call_1"), + FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'), + ] + ) + events = await bridge.from_agent_run_update(update) + + # Should not emit StateDeltaEvents + delta_events = [e for e in events if e.type == "STATE_DELTA"] + assert len(delta_events) == 0 + + +async def test_tool_call_without_name_or_id(): + """Test handling FunctionCallContent with no name and no call_id.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # This should not crash but log an error + update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallArgsEvent with generated ID + assert len(events) >= 1 + + +async def test_state_delta_count_logging(): + """Test that state delta count increments and logs at intervals.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}}, + ) + + # Emit multiple state deltas with different content each time + for i in range(15): + update = AgentRunResponseUpdate( + contents=[ + FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'), + ] + ) + # Set the tool name to match config + bridge.current_tool_call_name = "write" + await bridge.from_agent_run_update(update) + + # State delta count should have incremented (one per unique state update) + assert bridge.state_delta_count >= 1 diff --git a/python/packages/ag-ui/tests/test_human_in_the_loop.py b/python/packages/ag-ui/tests/test_human_in_the_loop.py new file mode 100644 index 0000000000..92f6d69926 --- /dev/null +++ b/python/packages/ag-ui/tests/test_human_in_the_loop.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for human in the loop (function approval requests).""" + +from agent_framework import FunctionApprovalRequestContent, FunctionCallContent +from agent_framework._types import AgentRunResponseUpdate + +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +async def test_function_approval_request_emission(): + """Test that CustomEvent is emitted for FunctionApprovalRequestContent.""" + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + ) + + # Create approval request + func_call = FunctionCallContent( + call_id="call_123", + name="send_email", + arguments={"to": "user@example.com", "subject": "Test"}, + ) + approval_request = FunctionApprovalRequestContent( + id="approval_001", + function_call=func_call, + ) + + update = AgentRunResponseUpdate(contents=[approval_request]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + CustomEvent for approval request + assert len(events) == 2 + + # First event: ToolCallEndEvent to close the tool call + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_123" + + # Second event: CustomEvent with approval details + event = events[1] + assert event.type == "CUSTOM" + assert event.name == "function_approval_request" + assert event.value["id"] == "approval_001" + assert event.value["function_call"]["call_id"] == "call_123" + assert event.value["function_call"]["name"] == "send_email" + assert event.value["function_call"]["arguments"]["to"] == "user@example.com" + assert event.value["function_call"]["arguments"]["subject"] == "Test" + + +async def test_multiple_approval_requests(): + """Test handling multiple approval requests in one update.""" + bridge = AgentFrameworkEventBridge( + run_id="test_run", + thread_id="test_thread", + ) + + func_call_1 = FunctionCallContent( + call_id="call_1", + name="create_event", + arguments={"title": "Meeting"}, + ) + approval_1 = FunctionApprovalRequestContent( + id="approval_1", + function_call=func_call_1, + ) + + func_call_2 = FunctionCallContent( + call_id="call_2", + name="book_room", + arguments={"room": "Conference A"}, + ) + approval_2 = FunctionApprovalRequestContent( + id="approval_2", + function_call=func_call_2, + ) + + update = AgentRunResponseUpdate(contents=[approval_1, approval_2]) + events = await bridge.from_agent_run_update(update) + + # Should emit ToolCallEndEvent + CustomEvent for each approval (4 events total) + assert len(events) == 4 + + # Events should alternate: End, Custom, End, Custom + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "call_1" + + assert events[1].type == "CUSTOM" + assert events[1].name == "function_approval_request" + assert events[1].value["id"] == "approval_1" + + assert events[2].type == "TOOL_CALL_END" + assert events[2].tool_call_id == "call_2" + + assert events[3].type == "CUSTOM" + assert events[3].name == "function_approval_request" + assert events[3].value["id"] == "approval_2" diff --git a/python/packages/ag-ui/tests/test_message_adapters.py b/python/packages/ag-ui/tests/test_message_adapters.py new file mode 100644 index 0000000000..1a5bb0ccd7 --- /dev/null +++ b/python/packages/ag-ui/tests/test_message_adapters.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for message adapters.""" + +import pytest +from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent + +from agent_framework_ag_ui._message_adapters import ( + agent_framework_messages_to_agui, + agui_messages_to_agent_framework, + extract_text_from_contents, +) + + +@pytest.fixture +def sample_agui_message(): + """Create a sample AG-UI message.""" + return {"role": "user", "content": "Hello", "id": "msg-123"} + + +@pytest.fixture +def sample_agent_framework_message(): + """Create a sample Agent Framework message.""" + return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123") + + +def test_agui_to_agent_framework_basic(sample_agui_message): + """Test converting AG-UI message to Agent Framework.""" + messages = agui_messages_to_agent_framework([sample_agui_message]) + + assert len(messages) == 1 + assert messages[0].role == Role.USER + assert messages[0].message_id == "msg-123" + + +def test_agent_framework_to_agui_basic(sample_agent_framework_message): + """Test converting Agent Framework message to AG-UI.""" + messages = agent_framework_messages_to_agui([sample_agent_framework_message]) + + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"] == "Hello" + assert messages[0]["id"] == "msg-123" + + +def test_agui_tool_result_to_agent_framework(): + """Test converting AG-UI tool result message to Agent Framework.""" + tool_result_message = { + "role": "tool", + "content": '{"accepted": true, "steps": []}', + "toolCallId": "call_123", + "id": "msg_456", + } + + messages = agui_messages_to_agent_framework([tool_result_message]) + + assert len(messages) == 1 + message = messages[0] + + assert message.role == Role.USER + + assert len(message.contents) == 1 + assert isinstance(message.contents[0], TextContent) + assert message.contents[0].text == '{"accepted": true, "steps": []}' + + assert hasattr(message, "metadata") + assert message.metadata is not None + assert message.metadata.get("is_tool_result") is True + assert message.metadata.get("tool_call_id") == "call_123" + + +def test_agui_multiple_messages_to_agent_framework(): + """Test converting multiple AG-UI messages.""" + messages_input = [ + {"role": "user", "content": "First message", "id": "msg-1"}, + {"role": "assistant", "content": "Second message", "id": "msg-2"}, + {"role": "user", "content": "Third message", "id": "msg-3"}, + ] + + messages = agui_messages_to_agent_framework(messages_input) + + assert len(messages) == 3 + assert messages[0].role == Role.USER + assert messages[1].role == Role.ASSISTANT + assert messages[2].role == Role.USER + + +def test_agui_empty_messages(): + """Test handling of empty messages list.""" + messages = agui_messages_to_agent_framework([]) + assert len(messages) == 0 + + +def test_agui_function_approvals(): + """Test converting function approvals from AG-UI to Agent Framework.""" + agui_msg = { + "role": "user", + "function_approvals": [ + { + "call_id": "call-1", + "name": "search", + "arguments": {"query": "test"}, + "approved": True, + "id": "approval-1", + }, + { + "call_id": "call-2", + "name": "update", + "arguments": {"value": 42}, + "approved": False, + "id": "approval-2", + }, + ], + "id": "msg-123", + } + + messages = agui_messages_to_agent_framework([agui_msg]) + + assert len(messages) == 1 + msg = messages[0] + assert msg.role == Role.USER + assert len(msg.contents) == 2 + + from agent_framework import FunctionApprovalResponseContent + + assert isinstance(msg.contents[0], FunctionApprovalResponseContent) + assert msg.contents[0].approved is True + assert msg.contents[0].id == "approval-1" + assert msg.contents[0].function_call.name == "search" + assert msg.contents[0].function_call.call_id == "call-1" + + assert isinstance(msg.contents[1], FunctionApprovalResponseContent) + assert msg.contents[1].approved is False + + +def test_agui_system_role(): + """Test converting system role messages.""" + messages = agui_messages_to_agent_framework([{"role": "system", "content": "System prompt"}]) + + assert len(messages) == 1 + assert messages[0].role == Role.SYSTEM + + +def test_agui_non_string_content(): + """Test handling non-string content.""" + messages = agui_messages_to_agent_framework([{"role": "user", "content": {"nested": "object"}}]) + + assert len(messages) == 1 + assert len(messages[0].contents) == 1 + assert isinstance(messages[0].contents[0], TextContent) + assert "nested" in messages[0].contents[0].text + + +def test_agui_message_without_id(): + """Test message without ID field.""" + messages = agui_messages_to_agent_framework([{"role": "user", "content": "No ID"}]) + + assert len(messages) == 1 + assert messages[0].message_id is None + + +def test_agent_framework_to_agui_with_tool_calls(): + """Test converting Agent Framework message with tool calls to AG-UI.""" + msg = ChatMessage( + role=Role.ASSISTANT, + contents=[ + TextContent(text="Calling tool"), + FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}), + ], + message_id="msg-456", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + assert agui_msg["role"] == "assistant" + assert agui_msg["content"] == "Calling tool" + assert "tool_calls" in agui_msg + assert len(agui_msg["tool_calls"]) == 1 + assert agui_msg["tool_calls"][0]["id"] == "call-123" + assert agui_msg["tool_calls"][0]["type"] == "function" + assert agui_msg["tool_calls"][0]["function"]["name"] == "search" + assert agui_msg["tool_calls"][0]["function"]["arguments"] == {"query": "test"} + + +def test_agent_framework_to_agui_multiple_text_contents(): + """Test concatenating multiple text contents.""" + msg = ChatMessage( + role=Role.ASSISTANT, + contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")], + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert messages[0]["content"] == "Part 1 Part 2" + + +def test_agent_framework_to_agui_no_message_id(): + """Test message without message_id.""" + msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert "id" not in messages[0] + + +def test_agent_framework_to_agui_system_role(): + """Test system role conversion.""" + msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")]) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + assert messages[0]["role"] == "system" + + +def test_extract_text_from_contents(): + """Test extracting text from contents list.""" + contents = [TextContent(text="Hello "), TextContent(text="World")] + + result = extract_text_from_contents(contents) + + assert result == "Hello World" + + +def test_extract_text_from_empty_contents(): + """Test extracting text from empty contents.""" + result = extract_text_from_contents([]) + + assert result == "" + + +class CustomTextContent: + """Custom content with text attribute.""" + + def __init__(self, text: str): + self.text = text + + +def test_extract_text_from_custom_contents(): + """Test extracting text from custom content objects.""" + contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")] + + result = extract_text_from_contents(contents) + + assert result == "Custom Mixed" diff --git a/python/packages/ag-ui/tests/test_shared_state.py b/python/packages/ag-ui/tests/test_shared_state.py new file mode 100644 index 0000000000..578d48ecd0 --- /dev/null +++ b/python/packages/ag-ui/tests/test_shared_state.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for shared state management.""" + +import pytest +from ag_ui.core import StateSnapshotEvent +from agent_framework import ChatAgent, TextContent +from agent_framework._types import ChatResponseUpdate + +from agent_framework_ag_ui._agent import AgentFrameworkAgent +from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + +@pytest.fixture +def mock_agent(): + """Create a mock agent for testing.""" + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Hello!")]) + + return ChatAgent( + name="test_agent", + instructions="Test agent", + chat_client=MockChatClient(), + ) + + +def test_state_snapshot_event(): + """Test creating state snapshot events.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + state = { + "recipe": { + "name": "Chocolate Chip Cookies", + "ingredients": ["flour", "sugar", "chocolate chips"], + "instructions": ["Mix ingredients", "Bake at 350°F"], + "servings": 24, + } + } + + event = bridge.create_state_snapshot_event(state) + + assert isinstance(event, StateSnapshotEvent) + assert event.snapshot == state + assert event.snapshot["recipe"]["name"] == "Chocolate Chip Cookies" + assert len(event.snapshot["recipe"]["ingredients"]) == 3 + + +def test_state_delta_event(): + """Test creating state delta events using JSON Patch format.""" + bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread") + + # JSON Patch operations (RFC 6902) + delta = [ + {"op": "add", "path": "/recipe/ingredients/-", "value": "vanilla extract"}, + {"op": "replace", "path": "/recipe/servings", "value": 30}, + ] + + event = bridge.create_state_delta_event(delta) + + assert event.delta == delta + assert len(event.delta) == 2 + assert event.delta[0]["op"] == "add" + assert event.delta[1]["op"] == "replace" + + +async def test_agent_with_initial_state(mock_agent): + """Test agent emits state snapshot when initial state provided.""" + state_schema = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}} + + agent = AgentFrameworkAgent( + agent=mock_agent, + state_schema=state_schema, + ) + + initial_state = {"recipe": {"name": "Test Recipe"}} + + input_data = { + "messages": [{"role": "user", "content": "Hello"}], + "state": initial_state, + } + + events = [] + async for event in agent.run_agent(input_data): + events.append(event) + + # Should have RunStartedEvent, StateSnapshotEvent, RunFinishedEvent at minimum + snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot == initial_state + + +async def test_agent_without_state_schema(mock_agent): + """Test agent doesn't emit state events without state schema.""" + agent = AgentFrameworkAgent(agent=mock_agent) + + input_data = { + "messages": [{"role": "user", "content": "Hello"}], + "state": {"some": "state"}, + } + + events = [] + async for event in agent.run_agent(input_data): + events.append(event) + + # Should NOT have any StateSnapshotEvent + snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)] + assert len(snapshot_events) == 0 diff --git a/python/packages/ag-ui/tests/test_structured_output.py b/python/packages/ag-ui/tests/test_structured_output.py new file mode 100644 index 0000000000..878002a8e1 --- /dev/null +++ b/python/packages/ag-ui/tests/test_structured_output.py @@ -0,0 +1,257 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for structured output handling in _agent.py.""" + +import json +from typing import Any + +from agent_framework import ChatAgent, ChatOptions, TextContent +from agent_framework._types import ChatResponseUpdate +from pydantic import BaseModel + + +class RecipeOutput(BaseModel): + """Test Pydantic model for recipe output.""" + + recipe: dict[str, Any] + message: str | None = None + + +class StepsOutput(BaseModel): + """Test Pydantic model for steps output.""" + + steps: list[dict[str, Any]] + message: str | None = None + + +class GenericOutput(BaseModel): + """Test Pydantic model for generic data.""" + + data: dict[str, Any] + + +async def test_structured_output_with_recipe(): + """Test structured output processing with recipe state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Simulate structured output + yield ChatResponseUpdate( + contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')] + ) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"recipe": {"type": "object"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Make pasta"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with recipe + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + # Find snapshot with recipe + recipe_snapshots = [e for e in snapshot_events if "recipe" in e.snapshot] + assert len(recipe_snapshots) >= 1 + assert recipe_snapshots[0].snapshot["recipe"] == {"name": "Pasta"} + + # Should also emit message as text + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert any("Here is your recipe" in e.delta for e in text_events) + + +async def test_structured_output_with_steps(): + """Test structured output processing with steps state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + steps_data = { + "steps": [ + {"id": "1", "description": "Step 1", "status": "pending"}, + {"id": "2", "description": "Step 2", "status": "pending"}, + ] + } + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=StepsOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"steps": {"type": "array"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Do steps"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with steps + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + + # Snapshot should contain steps + steps_snapshots = [e for e in snapshot_events if "steps" in e.snapshot] + assert len(steps_snapshots) >= 1 + assert len(steps_snapshots[0].snapshot["steps"]) == 2 + assert steps_snapshots[0].snapshot["steps"][0]["id"] == "1" + + +async def test_structured_output_with_no_schema_match(): + """Test structured output when response fields don't match state_schema keys.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Response has "data" field but schema expects "result" field + yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=GenericOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"result": {"type": "object"}}, # Schema expects "result", not "data" + ) + + input_data = {"messages": [{"role": "user", "content": "Generate data"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent but with no state updates since no schema fields match + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + # Initial state snapshot from state_schema initialization + assert len(snapshot_events) >= 1 + + +async def test_structured_output_without_schema(): + """Test structured output without state_schema treats all fields as state.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class DataOutput(BaseModel): + """Output with data and info fields.""" + + data: dict[str, Any] + info: str + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=DataOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + # No state_schema - all non-message fields treated as state + ) + + input_data = {"messages": [{"role": "user", "content": "Generate data"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit StateSnapshotEvent with both data and info fields + snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(snapshot_events) >= 1 + assert "data" in snapshot_events[0].snapshot + assert "info" in snapshot_events[0].snapshot + assert snapshot_events[0].snapshot["data"] == {"key": "value"} + assert snapshot_events[0].snapshot["info"] == "processed" + + +async def test_no_structured_output_when_no_response_format(): + """Test that structured output path is skipped when no response_format.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + yield ChatResponseUpdate(contents=[TextContent(text="Regular text")]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + # No response_format set + + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Hi"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit text content normally + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_events) > 0 + assert text_events[0].delta == "Regular text" + + +async def test_structured_output_with_message_field(): + """Test structured output that includes a message field.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"} + yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))]) + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent( + agent=agent, + state_schema={"recipe": {"type": "object"}}, + ) + + input_data = {"messages": [{"role": "user", "content": "Make salad"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should emit the message as text + text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert any("Fresh salad recipe ready" in e.delta for e in text_events) + + # Should also have TextMessageStart and TextMessageEnd + start_events = [e for e in events if e.type == "TEXT_MESSAGE_START"] + end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"] + assert len(start_events) >= 1 + assert len(end_events) >= 1 + + +async def test_empty_updates_no_structured_processing(): + """Test that empty updates don't trigger structured output processing.""" + from agent_framework_ag_ui import AgentFrameworkAgent + + class MockChatClient: + async def get_streaming_response(self, messages, chat_options, **kwargs): + # Return nothing + if False: + yield + + agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient()) + agent.chat_options = ChatOptions(response_format=RecipeOutput) + + wrapper = AgentFrameworkAgent(agent=agent) + + input_data = {"messages": [{"role": "user", "content": "Test"}]} + + events = [] + async for event in wrapper.run_agent(input_data): + events.append(event) + + # Should only have start and end events + assert len(events) == 2 # RunStarted, RunFinished diff --git a/python/packages/ag-ui/tests/test_types.py b/python/packages/ag-ui/tests/test_types.py new file mode 100644 index 0000000000..3c61278d9e --- /dev/null +++ b/python/packages/ag-ui/tests/test_types.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for type definitions in _types.py.""" + +from agent_framework_ag_ui._types import AgentState, PredictStateConfig, RunMetadata + + +class TestPredictStateConfig: + """Test PredictStateConfig TypedDict.""" + + def test_predict_state_config_creation(self) -> None: + """Test creating a PredictStateConfig dict.""" + config: PredictStateConfig = { + "state_key": "document", + "tool": "write_document", + "tool_argument": "content", + } + + assert config["state_key"] == "document" + assert config["tool"] == "write_document" + assert config["tool_argument"] == "content" + + def test_predict_state_config_with_none_tool_argument(self) -> None: + """Test PredictStateConfig with None tool_argument.""" + config: PredictStateConfig = { + "state_key": "status", + "tool": "update_status", + "tool_argument": None, + } + + assert config["state_key"] == "status" + assert config["tool"] == "update_status" + assert config["tool_argument"] is None + + def test_predict_state_config_type_validation(self) -> None: + """Test that PredictStateConfig validates field types at runtime.""" + config: PredictStateConfig = { + "state_key": "test", + "tool": "test_tool", + "tool_argument": "arg", + } + + assert isinstance(config["state_key"], str) + assert isinstance(config["tool"], str) + assert isinstance(config["tool_argument"], (str, type(None))) + + +class TestRunMetadata: + """Test RunMetadata TypedDict.""" + + def test_run_metadata_creation(self) -> None: + """Test creating a RunMetadata dict.""" + metadata: RunMetadata = { + "run_id": "run-123", + "thread_id": "thread-456", + "predict_state": [ + { + "state_key": "document", + "tool": "write_document", + "tool_argument": "content", + } + ], + } + + assert metadata["run_id"] == "run-123" + assert metadata["thread_id"] == "thread-456" + assert metadata["predict_state"] is not None + assert len(metadata["predict_state"]) == 1 + assert metadata["predict_state"][0]["state_key"] == "document" + + def test_run_metadata_with_none_predict_state(self) -> None: + """Test RunMetadata with None predict_state.""" + metadata: RunMetadata = { + "run_id": "run-789", + "thread_id": "thread-012", + "predict_state": None, + } + + assert metadata["run_id"] == "run-789" + assert metadata["thread_id"] == "thread-012" + assert metadata["predict_state"] is None + + def test_run_metadata_empty_predict_state(self) -> None: + """Test RunMetadata with empty predict_state list.""" + metadata: RunMetadata = { + "run_id": "run-345", + "thread_id": "thread-678", + "predict_state": [], + } + + assert metadata["run_id"] == "run-345" + assert metadata["thread_id"] == "thread-678" + assert metadata["predict_state"] == [] + + +class TestAgentState: + """Test AgentState TypedDict.""" + + def test_agent_state_creation(self) -> None: + """Test creating an AgentState dict.""" + state: AgentState = { + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + } + + assert state["messages"] is not None + assert len(state["messages"]) == 2 + assert state["messages"][0]["role"] == "user" + assert state["messages"][1]["role"] == "assistant" + + def test_agent_state_with_none_messages(self) -> None: + """Test AgentState with None messages.""" + state: AgentState = {"messages": None} + + assert state["messages"] is None + + def test_agent_state_empty_messages(self) -> None: + """Test AgentState with empty messages list.""" + state: AgentState = {"messages": []} + + assert state["messages"] == [] + + def test_agent_state_complex_messages(self) -> None: + """Test AgentState with complex message structures.""" + state: AgentState = { + "messages": [ + { + "role": "user", + "content": "Test", + "metadata": {"timestamp": "2025-10-30"}, + }, + { + "role": "assistant", + "content": "Response", + "tool_calls": [{"name": "search", "args": {}}], + }, + ] + } + + assert state["messages"] is not None + assert len(state["messages"]) == 2 + assert "metadata" in state["messages"][0] + assert "tool_calls" in state["messages"][1] diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py new file mode 100644 index 0000000000..9bc477310c --- /dev/null +++ b/python/packages/ag-ui/tests/test_utils.py @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for utilities.""" + +from dataclasses import dataclass +from datetime import date, datetime + +from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state + + +def test_generate_event_id(): + """Test event ID generation.""" + id1 = generate_event_id() + id2 = generate_event_id() + + assert id1 != id2 + assert isinstance(id1, str) + assert len(id1) > 0 + + +def test_merge_state(): + """Test state merging.""" + current = {"a": 1, "b": 2} + update = {"b": 3, "c": 4} + + result = merge_state(current, update) + + assert result["a"] == 1 + assert result["b"] == 3 + assert result["c"] == 4 + + +def test_merge_state_empty_update(): + """Test merging with empty update.""" + current = {"x": 10, "y": 20} + update = {} + + result = merge_state(current, update) + + assert result == current + assert result is not current + + +def test_merge_state_empty_current(): + """Test merging with empty current state.""" + current = {} + update = {"a": 1, "b": 2} + + result = merge_state(current, update) + + assert result == update + + +def test_merge_state_deep_copy(): + """Test that merge_state creates a deep copy preventing mutation of original.""" + current = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}} + update = {"other": "value"} + + result = merge_state(current, update) + + result["recipe"]["ingredients"].append("eggs") + + assert "eggs" not in current["recipe"]["ingredients"] + assert current["recipe"]["ingredients"] == ["flour", "sugar"] + assert result["recipe"]["ingredients"] == ["flour", "sugar", "eggs"] + + +def test_make_json_safe_basic(): + """Test JSON serialization of basic types.""" + assert make_json_safe("text") == "text" + assert make_json_safe(123) == 123 + assert make_json_safe(None) is None + assert make_json_safe(3.14) == 3.14 + assert make_json_safe(True) is True + assert make_json_safe(False) is False + + +def test_make_json_safe_datetime(): + """Test datetime serialization.""" + dt = datetime(2025, 10, 30, 12, 30, 45) + result = make_json_safe(dt) + assert result == "2025-10-30T12:30:45" + + +def test_make_json_safe_date(): + """Test date serialization.""" + d = date(2025, 10, 30) + result = make_json_safe(d) + assert result == "2025-10-30" + + +@dataclass +class SampleDataclass: + """Sample dataclass for testing.""" + + name: str + value: int + + +def test_make_json_safe_dataclass(): + """Test dataclass serialization.""" + obj = SampleDataclass(name="test", value=42) + result = make_json_safe(obj) + assert result == {"name": "test", "value": 42} + + +class ModelDumpObject: + """Object with model_dump method.""" + + def model_dump(self): + return {"type": "model", "data": "dump"} + + +def test_make_json_safe_model_dump(): + """Test object with model_dump method.""" + obj = ModelDumpObject() + result = make_json_safe(obj) + assert result == {"type": "model", "data": "dump"} + + +class DictObject: + """Object with dict method.""" + + def dict(self): + return {"type": "dict", "method": "call"} + + +def test_make_json_safe_dict_method(): + """Test object with dict method.""" + obj = DictObject() + result = make_json_safe(obj) + assert result == {"type": "dict", "method": "call"} + + +class CustomObject: + """Custom object with __dict__.""" + + def __init__(self): + self.field1 = "value1" + self.field2 = 123 + + +def test_make_json_safe_dict_attribute(): + """Test object with __dict__ attribute.""" + obj = CustomObject() + result = make_json_safe(obj) + assert result == {"field1": "value1", "field2": 123} + + +def test_make_json_safe_list(): + """Test list serialization.""" + lst = [1, "text", None, {"key": "value"}] + result = make_json_safe(lst) + assert result == [1, "text", None, {"key": "value"}] + + +def test_make_json_safe_tuple(): + """Test tuple serialization.""" + tpl = (1, 2, 3) + result = make_json_safe(tpl) + assert result == [1, 2, 3] + + +def test_make_json_safe_dict(): + """Test dict serialization.""" + d = {"a": 1, "b": {"c": 2}} + result = make_json_safe(d) + assert result == {"a": 1, "b": {"c": 2}} + + +def test_make_json_safe_nested(): + """Test nested structure serialization.""" + obj = { + "datetime": datetime(2025, 10, 30), + "list": [1, 2, CustomObject()], + "nested": {"value": SampleDataclass(name="nested", value=99)}, + } + result = make_json_safe(obj) + + assert result["datetime"] == "2025-10-30T00:00:00" + assert result["list"][0] == 1 + assert result["list"][2] == {"field1": "value1", "field2": 123} + assert result["nested"]["value"] == {"name": "nested", "value": 99} + + +class UnserializableObject: + """Object that can't be serialized by standard methods.""" + + def __init__(self): + # Add attribute to trigger __dict__ fallback path + pass + + +def test_make_json_safe_fallback(): + """Test fallback to dict for objects with __dict__.""" + obj = UnserializableObject() + result = make_json_safe(obj) + # Objects with __dict__ return their __dict__ dict + assert isinstance(result, dict) diff --git a/python/pyproject.toml b/python/pyproject.toml index 8db0916229..72eb4ba258 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core", "agent-framework-a2a", + "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-chatkit", @@ -89,6 +90,7 @@ members = [ "packages/*" ] agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } +agent-framework-ag-ui = { workspace = true } agent-framework-azure-ai = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-copilotstudio = { workspace = true } @@ -241,6 +243,7 @@ cmd = """ pytest --import-mode=importlib --cov=agent_framework --cov=agent_framework_a2a +--cov=agent_framework_ag_ui --cov=agent_framework_azure_ai --cov=agent_framework_chatkit --cov=agent_framework_copilotstudio diff --git a/python/uv.lock b/python/uv.lock index 7e9e6798a7..19df6a548f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ supported-markers = [ members = [ "agent-framework", "agent-framework-a2a", + "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-chatkit", @@ -72,12 +73,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, ] +[[package]] +name = "ag-ui-protocol" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/d7/a8f8789b3b8b5f7263a902361468e8dfefd85ec63d1d5398579b9175d76d/ag_ui_protocol-0.1.9.tar.gz", hash = "sha256:94d75e3919ff75e0b608a7eed445062ea0e6f11cd33b3386a7649047e0c7abd3", size = 4988, upload-time = "2025-09-19T13:36:26.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/50/2bb71a2a9135f4d88706293773320d185789b592987c09f79e9bf2f4875f/ag_ui_protocol-0.1.9-py3-none-any.whl", hash = "sha256:44c1238b0576a3915b3a16e1b3855724e08e92ebc96b1ff29379fbd3bfbd400b", size = 7070, upload-time = "2025-09-19T13:36:25.791Z" }, +] + [[package]] name = "agent-framework" version = "1.0.0b251104" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -121,6 +135,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "agent-framework-a2a", editable = "packages/a2a" }, + { name = "agent-framework-ag-ui", editable = "packages/ag-ui" }, { name = "agent-framework-anthropic", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", editable = "packages/azure-ai" }, { name = "agent-framework-chatkit", editable = "packages/chatkit" }, @@ -176,6 +191,36 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, ] +[[package]] +name = "agent-framework-ag-ui" +version = "0.1.0" +source = { editable = "packages/ag-ui" } +dependencies = [ + { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "ag-ui-protocol", specifier = ">=0.1.9" }, + { name = "agent-framework-core", editable = "packages/core" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "uvicorn", specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + [[package]] name = "agent-framework-anthropic" version = "1.0.0b251104" @@ -679,6 +724,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -733,14 +787,14 @@ wheels = [ [[package]] name = "apscheduler" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, ] [[package]] @@ -1650,16 +1704,17 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.121.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" }, ] [[package]] @@ -2264,11 +2319,11 @@ wheels = [ [[package]] name = "httpdbg" -version = "2.1.1" +version = "2.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/07/bdc4b46bf9cda06b4bdb5b0dea2e1c5408fd91387823e1cb2cfebd79fde4/httpdbg-2.1.1.tar.gz", hash = "sha256:11b268e9224fdeccc7e5436b350154c287a1af65406047b5f6438461fc45486c", size = 81226, upload-time = "2025-10-26T18:42:41.896Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/c0/a54d8705ae57e76679cf21dbc6dba3eb4c5cb9f99fcd9cb99e159fb12a9d/httpdbg-2.1.3.tar.gz", hash = "sha256:da32fd7cab8032927ba4717c6c9108dd4aeb0d9a42636d34a43ab11541daac26", size = 80694, upload-time = "2025-11-02T13:48:13.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/8e/8b0e91e4c5426f503149f86df5b2a142afb11abada57cf09a5990a933407/httpdbg-2.1.1-py3-none-any.whl", hash = "sha256:ef7137752cb2c79b3084b50a9534e7d1ba587d9ad531ac0807a3563ceb7a74e0", size = 89045, upload-time = "2025-10-26T18:42:39.017Z" }, + { url = "https://files.pythonhosted.org/packages/33/6e/567ace955933023403e4861d161de8b559d712b559e445cc6d9a95d8e26c/httpdbg-2.1.3-py3-none-any.whl", hash = "sha256:9faa4d66f308670ddde0c6b05281066cb10b56846e6c4d3eb712123c28ea019d", size = 88173, upload-time = "2025-11-02T13:48:12.466Z" }, ] [[package]] @@ -2681,7 +2736,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.8.1" +version = "3.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2695,14 +2750,14 @@ dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/0b/81f9c6a982f79c112b7f10bfd6f3a4871e6fa3e4fe8d078b6112abfd3c08/langfuse-3.8.1.tar.gz", hash = "sha256:2464ae3f8386d80e1252a0e7406e3be4121e792a74f1b1c21d9950f658e5168d", size = 197401, upload-time = "2025-10-22T13:35:52.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c6/1bdb6c68ebc2b7d3875861cf99715e227bcd909a758df8af329f81f6e7af/langfuse-3.9.0.tar.gz", hash = "sha256:ed02744ab184a320dba5662be09be21441a467cc84db7e9a67c8bb6baec9fb5c", size = 201850, upload-time = "2025-11-03T10:25:49.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/f9/538af0fc4219eb2484ba319483bce3383146f7a0923d5f39e464ad9a504b/langfuse-3.8.1-py3-none-any.whl", hash = "sha256:5b94b66ec0b0de388a8ea1f078b32c1666b5825b36eab863a21fdee78c53b3bb", size = 364580, upload-time = "2025-10-22T13:35:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/66/de/66ab298aecc0b50465824e7db5df77e43f872dcd8642d3c91d11be3ac6f7/langfuse-3.9.0-py3-none-any.whl", hash = "sha256:de46c47717822de46ad4a2563be5d775ca896dc4d0955a83b4d12e1ce5e249a9", size = 369620, upload-time = "2025-11-03T10:25:47.747Z" }, ] [[package]] name = "litellm" -version = "1.79.0" +version = "1.79.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2718,9 +2773,9 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/52/2853febf8ea3072d8c76e3ee22d3168e6a4f97ebd8f21905e815a381c58b/litellm-1.79.0.tar.gz", hash = "sha256:f58bb751222ee0e1ffecb2d44987999f9fa94130a6d1a478e19a3e5e8b9a7414", size = 11146414, upload-time = "2025-10-26T01:20:55.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/12/1c30f1019892399a488ed60ebcdfed3e2603123d9591030abc8c702ff37a/litellm-1.79.1.tar.gz", hash = "sha256:c1cf0232c01e7ad4b8442d2cdd78973ce74dfda37ad1d9f0ec3c911510e26523", size = 11216675, upload-time = "2025-11-01T19:22:05.523Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/26/a5fef380af5d6a2f47cda979d88561af1e1a8efc07da2ef72c0e8cb6842c/litellm-1.79.0-py3-none-any.whl", hash = "sha256:93414b6ed55fa9e3268e8cb3100faab960c9ecd18173129ccd85471cf3db4f1a", size = 10197864, upload-time = "2025-10-26T01:20:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/ac5905dfe9c0c195e59c36ea431277090dd2aa1acbcc514f781fa87a5903/litellm-1.79.1-py3-none-any.whl", hash = "sha256:738f7bf36b31514ac11cc71f65718238b57696fcf22f8b3f1e57c44daf17a569", size = 10285849, upload-time = "2025-11-01T19:22:01.637Z" }, ] [package.optional-dependencies] @@ -2761,11 +2816,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.2.29" +version = "0.2.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/23/8b262f0301e02f7a70f299e68d06752934f6dd95d0a6b82ce871e5de4d81/litellm_proxy_extras-0.2.29.tar.gz", hash = "sha256:236c1cf8d9b0128392bb843ff8553918b0a9c299f2b3bfdc9ecc6b4547ce195e", size = 16500, upload-time = "2025-10-23T21:19:10.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/007a87b17834c5a24e15798ae32dd156d77528b12f086f4176bb7e3f4401/litellm_proxy_extras-0.2.31.tar.gz", hash = "sha256:6d4c96dfe28fa439eaf4e8d19b73718530bc2c59cd1e4cf560388c6bce5476bb", size = 16648, upload-time = "2025-11-01T01:18:47.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/70/c8ec18235f4bbe3c8486b2909c3d5fc23cdbd08b2c7504ae8c02ed813c83/litellm_proxy_extras-0.2.29-py3-none-any.whl", hash = "sha256:27b7efc69829ed8745de7f469110c1f6a82e4f994bd8de3ac6b16dc2806a14b0", size = 33565, upload-time = "2025-10-23T21:19:09.821Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5f/6a0add2cac34a370da62d3bf7476035f5f10519740dfe78410256f8945b1/litellm_proxy_extras-0.2.31-py3-none-any.whl", hash = "sha256:7a66ae2810e451977fb1dfed6dac81971c6a4efbce7d57c896dce280b50ce359", size = 34130, upload-time = "2025-11-01T01:18:46.485Z" }, ] [[package]] @@ -2955,7 +3010,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.19.0" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2964,15 +3019,16 @@ dependencies = [ { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/22/fae38092e6c2995c03232635028510d77e7decff31b4ae79dfa0ba99c635/mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177", size = 451377, upload-time = "2025-10-30T22:14:53.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/df/00/76fc92f4892d47fecb37131d0e95ea69259f077d84c68f6793a0d96cfe80/mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d", size = 173136, upload-time = "2025-10-30T22:14:51.078Z" }, ] [package.optional-dependencies] @@ -3009,31 +3065,31 @@ wheels = [ [[package]] name = "microsoft-agents-activity" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/96/4416c5b3f13309d7503f3db3c2bfc321824366b68a240ed71e8145634c3d/microsoft_agents_activity-0.5.1.tar.gz", hash = "sha256:07be29aca58ea9d8279155cfa4c00261e3a18bdf718c8164c1d87e3e57ad527b", size = 55830, upload-time = "2025-10-28T19:27:03.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/51/2698980f425cda122f5b755a957c3c2db604c0b9a787c6add5aa4649c237/microsoft_agents_activity-0.5.3.tar.gz", hash = "sha256:d80b055591df561df8cebda9e1712012352581a396b36459133a951982b3a760", size = 55892, upload-time = "2025-10-31T15:40:49.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/47/333591538c134b5b4637ffc8ab4f5d0bf1c1b6310e3cfb5adc4002aa5940/microsoft_agents_activity-0.5.1-py3-none-any.whl", hash = "sha256:07562064125f2bc8066c2c8e9a60ff6f038f7413ccd01a9d9b0aa426e47467cd", size = 127817, upload-time = "2025-10-28T19:27:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/9618243e7b6f1f6295642c4e2dfca65b3a37794efbe1bdec15f0a93827d9/microsoft_agents_activity-0.5.3-py3-none-any.whl", hash = "sha256:5ae2447ac47c32f03c614694f520817cd225c9c502ec08b90d448311fb5bf3b4", size = 127861, upload-time = "2025-10-31T15:40:57.628Z" }, ] [[package]] name = "microsoft-agents-copilotstudio-client" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/53/e6dde964b677358ec7c177c4aa2d408cd31acba4abe3d24c4728c2607b3d/microsoft_agents_copilotstudio_client-0.5.1.tar.gz", hash = "sha256:0b730045b4f8e8f61291279e64e0669868ace39beb63688ec38ba181020f5c3f", size = 11153, upload-time = "2025-10-28T19:27:06.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/22/109164fb585c4baee40d2372c5d76254ec4a28219908f11cd27ac92aa6c1/microsoft_agents_copilotstudio_client-0.5.3.tar.gz", hash = "sha256:a57ea6b3cb47dbb5ad22e59c986208ace6479e35da3f644e6346f4dfd85db57c", size = 11161, upload-time = "2025-10-31T15:40:51.444Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/cd/50576ae8cbb2cd7fe0ebaa3ae882fc69cfb29183a5304cda29ba09084faa/microsoft_agents_copilotstudio_client-0.5.1-py3-none-any.whl", hash = "sha256:115f6aff0e44b97fd23128b7d4d53b6ed10ec54f93494c569c1cb48ac2b8a468", size = 11091, upload-time = "2025-10-28T19:27:14.469Z" }, + { url = "https://files.pythonhosted.org/packages/c4/65/984e139c85657ff0c8df0ed98a167c8b9434f4fd4f32862b4a6490b8c714/microsoft_agents_copilotstudio_client-0.5.3-py3-none-any.whl", hash = "sha256:6a36fce5c8c1a2df6f5142e35b12c69be80959ecff6d60cc309661018c40f00a", size = 11091, upload-time = "2025-10-31T15:40:59.718Z" }, ] [[package]] name = "microsoft-agents-hosting-core" -version = "0.5.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3042,9 +3098,9 @@ dependencies = [ { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/0b/71bc8f2fd673de9f8a0d7e9bef30dd15892d8539c4557129a5aead2c5882/microsoft_agents_hosting_core-0.5.1.tar.gz", hash = "sha256:d9b64095bf7624d4fc9f1d48cea5a3c66cc2dee9e1c3fb6ea3e9b6dfc03ace8f", size = 81277, upload-time = "2025-10-28T19:27:08.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/98/7755c07b2ae5faf3e4dc14b17e44680a600c8b840b3003fb326d5720dea1/microsoft_agents_hosting_core-0.5.3.tar.gz", hash = "sha256:b113d4ea5c9e555bbf61037bb2a1a7a3ce7e5e4a7a0f681a3bd4719ba72ff821", size = 81672, upload-time = "2025-10-31T15:40:53.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/2c/bcb8d66ebfe59cf6093c5eac1fc19a7797b5b80ce3ceaec07f2954a21493/microsoft_agents_hosting_core-0.5.1-py3-none-any.whl", hash = "sha256:10a1f394d8e444f6e2e74ab935f5c0a04ebfa43d136be4658fbaccab1321c37e", size = 120190, upload-time = "2025-10-28T19:27:16.263Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/c9e98475971c9da9cc9ff88195bbfcfae90dba511ebe14610be79f23ab3f/microsoft_agents_hosting_core-0.5.3-py3-none-any.whl", hash = "sha256:8c228a8814dcf1a86dd60e4c7574a2e86078962695fabd693a118097e703e982", size = 120668, upload-time = "2025-10-31T15:41:01.691Z" }, ] [[package]] @@ -3318,11 +3374,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.10.0" +version = "2.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/e5/ef07d31c2e07d99eecac8e14ace5c20aeb00ecba4ed5bb00343136380524/narwhals-2.10.0.tar.gz", hash = "sha256:1c05bbef2048a4045263de7d98c3d06140583eb13d796dd733b2157f05d24485", size = 582423, upload-time = "2025-10-27T17:55:55.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/dc/8db74daf8c2690ec696c1d772a33cc01511559ee8a9e92d7ed85a18e3c22/narwhals-2.10.2.tar.gz", hash = "sha256:ff738a08bc993cbb792266bec15346c1d85cc68fdfe82a23283c3713f78bd354", size = 584954, upload-time = "2025-11-04T16:36:42.281Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/13/024ae0586d901f8a6f99e2d29b4ae217e8ef11d3fd944cdfc3bbde5f2a08/narwhals-2.10.0-py3-none-any.whl", hash = "sha256:baed44e8fc38e800e3a585e3fa9843a7079a6fad5fbffbecee4348d6ac52298c", size = 418077, upload-time = "2025-10-27T17:55:53.709Z" }, + { url = "https://files.pythonhosted.org/packages/47/a9/9e02fa97e421a355fc5e818e9c488080fce04a8e0eebb3ed75a84f041c4a/narwhals-2.10.2-py3-none-any.whl", hash = "sha256:059cd5c6751161b97baedcaf17a514c972af6a70f36a89af17de1a0caf519c43", size = 419573, upload-time = "2025-11-04T16:36:40.574Z" }, ] [[package]] @@ -4003,15 +4059,15 @@ wheels = [ [[package]] name = "plotly" -version = "6.3.1" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/63/961d47c9ffd592a575495891cdcf7875dc0903ebb33ac238935714213789/plotly-6.3.1.tar.gz", hash = "sha256:dd896e3d940e653a7ce0470087e82c2bd903969a55e30d1b01bb389319461bb0", size = 6956460, upload-time = "2025-10-02T16:10:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/e6/b768650072837505804bed4790c5449ba348a3b720e27ca7605414e998cd/plotly-6.4.0.tar.gz", hash = "sha256:68c6db2ed2180289ef978f087841148b7efda687552276da15a6e9b92107052a", size = 7012379, upload-time = "2025-11-04T17:59:26.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/93/023955c26b0ce614342d11cc0652f1e45e32393b6ab9d11a664a60e9b7b7/plotly-6.3.1-py3-none-any.whl", hash = "sha256:8b4420d1dcf2b040f5983eed433f95732ed24930e496d36eb70d211923532e64", size = 9833698, upload-time = "2025-10-02T16:10:22.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/ae/89b45ccccfeebc464c9233de5675990f75241b8ee4cd63227800fdf577d1/plotly-6.4.0-py3-none-any.whl", hash = "sha256:a1062eafbdc657976c2eedd276c90e184ccd6c21282a5e9ee8f20efca9c9a4c5", size = 9892458, upload-time = "2025-11-04T17:59:22.622Z" }, ] [[package]] @@ -4086,7 +4142,7 @@ wheels = [ [[package]] name = "posthog" -version = "6.7.11" +version = "6.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4096,9 +4152,9 @@ dependencies = [ { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/32/3668d5e0f8b852fad81770743ee17893854fd8e5f7cea897a0a9199b0370/posthog-6.7.11.tar.gz", hash = "sha256:62db3e97cbd95351fe081c1ea8805393293de6fabad6d2e9024bf940aca4ddbf", size = 120407, upload-time = "2025-10-28T13:06:18.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/26/fbd8a29d094c1b3df109b79f7165ddb20dc37ec1e5b55717585de9ee9b65/posthog-6.8.0.tar.gz", hash = "sha256:40bc3bffe4818d37de63a4f4f13d2e90a78efe14f0d808c962f0ffebc3b15256", size = 122781, upload-time = "2025-11-04T19:43:34.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/00/bf284e0aae5dec7c217c176f291867cfac2f7bfd5692c9ce041e80986fa7/posthog-6.7.11-py3-none-any.whl", hash = "sha256:31421a88437cef2ce20f60c14ee8d298b2e765a6de0617cb95d1fcef54170749", size = 138713, upload-time = "2025-10-28T13:06:17.018Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/970fe48b888c53de5768f67524444c2adf2ea86fba97a672434deb8db971/posthog-6.8.0-py3-none-any.whl", hash = "sha256:b30b3cb06234d9177cecabe6f3e04e34e1e15fe7b60428771a67be57920a6308", size = 141210, upload-time = "2025-11-04T19:43:33.375Z" }, ] [[package]] @@ -4916,109 +4972,109 @@ wheels = [ [[package]] name = "regex" -version = "2025.10.23" +version = "2025.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/c8/1d2160d36b11fbe0a61acb7c3c81ab032d9ec8ad888ac9e0a61b85ab99dd/regex-2025.10.23.tar.gz", hash = "sha256:8cbaf8ceb88f96ae2356d01b9adf5e6306fa42fa6f7eab6b97794e37c959ac26", size = 401266, upload-time = "2025-10-21T15:58:20.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/11/849d5d23633a77047465eaae4cc0cbf24ded7aa496c02e8b9710e28b1687/regex-2025.10.23-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:17bbcde374bef1c5fad9b131f0e28a6a24856dd90368d8c0201e2b5a69533daa", size = 487957, upload-time = "2025-10-21T15:54:26.151Z" }, - { url = "https://files.pythonhosted.org/packages/87/12/5985386e7e3200a0d6a6417026d2c758d783a932428a5efc0a42ca1ddf74/regex-2025.10.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4e10434279cc8567f99ca6e018e9025d14f2fded2a603380b6be2090f476426", size = 290419, upload-time = "2025-10-21T15:54:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/67/cf/a8615923f962f8fdc41a3a6093a48726955e8b1993f4614b26a41d249f9b/regex-2025.10.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c9bb421cbe7012c744a5a56cf4d6c80829c72edb1a2991677299c988d6339c8", size = 288285, upload-time = "2025-10-21T15:54:30.47Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3d/6a3a1e12c86354cd0b3cbf8c3dd6acbe853609ee3b39d47ecd3ce95caf84/regex-2025.10.23-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:275cd1c2ed8c4a78ebfa489618d7aee762e8b4732da73573c3e38236ec5f65de", size = 781458, upload-time = "2025-10-21T15:54:31.978Z" }, - { url = "https://files.pythonhosted.org/packages/46/47/76a8da004489f2700361754859e373b87a53d043de8c47f4d1583fd39d78/regex-2025.10.23-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b426ae7952f3dc1e73a86056d520bd4e5f021397484a6835902fc5648bcacce", size = 850605, upload-time = "2025-10-21T15:54:33.753Z" }, - { url = "https://files.pythonhosted.org/packages/67/05/fa886461f97d45a6f4b209699cb994dc6d6212d6e219d29444dac5005775/regex-2025.10.23-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5cdaf5b6d37c7da1967dbe729d819461aab6a98a072feef65bbcff0a6e60649", size = 898563, upload-time = "2025-10-21T15:54:35.431Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/3ddd8d01455f23cabad7499f4199de0df92f5e96d39633203ff9d0b592dc/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bfeff0b08f296ab28b4332a7e03ca31c437ee78b541ebc874bbf540e5932f8d", size = 791535, upload-time = "2025-10-21T15:54:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ae/0fa5cbf41ca92b6ec3370222fcb6c68b240d68ab10e803d086c03a19fd9e/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f97236a67307b775f30a74ef722b64b38b7ab7ba3bb4a2508518a5de545459c", size = 782461, upload-time = "2025-10-21T15:54:39.187Z" }, - { url = "https://files.pythonhosted.org/packages/d4/23/70af22a016df11af4def27870eb175c2c7235b72d411ecf75a4b4a422cb6/regex-2025.10.23-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:be19e7de499940cd72475fb8e46ab2ecb1cf5906bebdd18a89f9329afb1df82f", size = 774583, upload-time = "2025-10-21T15:54:41.018Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ee/a54a6851f6905f33d3c4ed64e8737b1d85ed01b5724712530ddc0f9abdb1/regex-2025.10.23-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:883df76ee42d9ecb82b37ff8d01caea5895b3f49630a64d21111078bbf8ef64c", size = 845649, upload-time = "2025-10-21T15:54:42.615Z" }, - { url = "https://files.pythonhosted.org/packages/80/7d/c3ec1cae14e01fab00e38c41ed35f47a853359e95e9c023e9a4381bb122c/regex-2025.10.23-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2e9117d1d35fc2addae6281019ecc70dc21c30014b0004f657558b91c6a8f1a7", size = 836037, upload-time = "2025-10-21T15:54:44.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/45771140dd43c4d67c87b54d3728078ed6a96599d9fc7ba6825086236782/regex-2025.10.23-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ff1307f531a5d8cf5c20ea517254551ff0a8dc722193aab66c656c5a900ea68", size = 779705, upload-time = "2025-10-21T15:54:46.08Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/074e2581760eafce7c816a352b7d3a322536e5b68c346d1a8bacd895545c/regex-2025.10.23-cp310-cp310-win32.whl", hash = "sha256:7888475787cbfee4a7cd32998eeffe9a28129fa44ae0f691b96cb3939183ef41", size = 265663, upload-time = "2025-10-21T15:54:47.854Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c7/a25f56a718847e34d3f1608c72eadeb67653bff1a0411da023dd8f4c647b/regex-2025.10.23-cp310-cp310-win_amd64.whl", hash = "sha256:ec41a905908496ce4906dab20fb103c814558db1d69afc12c2f384549c17936a", size = 277587, upload-time = "2025-10-21T15:54:49.571Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e5/63eb17c6b5deaefd93c2bbb1feae7c0a8d2157da25883a6ca2569cf7a663/regex-2025.10.23-cp310-cp310-win_arm64.whl", hash = "sha256:b2b7f19a764d5e966d5a62bf2c28a8b4093cc864c6734510bdb4aeb840aec5e6", size = 269979, upload-time = "2025-10-21T15:54:51.375Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/74b7cd5cd76b4171f9793042045bb1726f7856dd56e582fc3e058a7a8a5e/regex-2025.10.23-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c531155bf9179345e85032052a1e5fe1a696a6abf9cea54b97e8baefff970fd", size = 487960, upload-time = "2025-10-21T15:54:53.253Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/854fa4b3b20471d1df1c71e831b6a1aa480281e37791e52a2df9641ec5c6/regex-2025.10.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:912e9df4e89d383681268d38ad8f5780d7cccd94ba0e9aa09ca7ab7ab4f8e7eb", size = 290425, upload-time = "2025-10-21T15:54:55.21Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d3/6272b1dd3ca1271661e168762b234ad3e00dbdf4ef0c7b9b72d2d159efa7/regex-2025.10.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f375c61bfc3138b13e762fe0ae76e3bdca92497816936534a0177201666f44f", size = 288278, upload-time = "2025-10-21T15:54:56.862Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/c7b365dd9d9bc0a36e018cb96f2ffb60d2ba8deb589a712b437f67de2920/regex-2025.10.23-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e248cc9446081119128ed002a3801f8031e0c219b5d3c64d3cc627da29ac0a33", size = 793289, upload-time = "2025-10-21T15:54:58.352Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/b8fbe9aa16cf0c21f45ec5a6c74b4cecbf1a1c0deb7089d4a6f83a9c1caa/regex-2025.10.23-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b52bf9282fdf401e4f4e721f0f61fc4b159b1307244517789702407dd74e38ca", size = 860321, upload-time = "2025-10-21T15:54:59.813Z" }, - { url = "https://files.pythonhosted.org/packages/b0/81/bf41405c772324926a9bd8a640dedaa42da0e929241834dfce0733070437/regex-2025.10.23-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c084889ab2c59765a0d5ac602fd1c3c244f9b3fcc9a65fdc7ba6b74c5287490", size = 907011, upload-time = "2025-10-21T15:55:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fb/5ad6a8b92d3f88f3797b51bb4ef47499acc2d0b53d2fbe4487a892f37a73/regex-2025.10.23-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80e8eb79009bdb0936658c44ca06e2fbbca67792013e3818eea3f5f228971c2", size = 800312, upload-time = "2025-10-21T15:55:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/42/48/b4efba0168a2b57f944205d823f8e8a3a1ae6211a34508f014ec2c712f4f/regex-2025.10.23-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6f259118ba87b814a8ec475380aee5f5ae97a75852a3507cf31d055b01b5b40", size = 782839, upload-time = "2025-10-21T15:55:05.641Z" }, - { url = "https://files.pythonhosted.org/packages/13/2a/c9efb4c6c535b0559c1fa8e431e0574d229707c9ca718600366fcfef6801/regex-2025.10.23-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b8c72a242683dcc72d37595c4f1278dfd7642b769e46700a8df11eab19dfd82", size = 854270, upload-time = "2025-10-21T15:55:07.27Z" }, - { url = "https://files.pythonhosted.org/packages/34/2d/68eecc1bdaee020e8ba549502291c9450d90d8590d0552247c9b543ebf7b/regex-2025.10.23-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d7b7a0a3df9952f9965342159e0c1f05384c0f056a47ce8b61034f8cecbe83", size = 845771, upload-time = "2025-10-21T15:55:09.477Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/a1ae499cf9b87afb47a67316bbf1037a7c681ffe447c510ed98c0aa2c01c/regex-2025.10.23-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:413bfea20a484c524858125e92b9ce6ffdd0a4b97d4ff96b5859aa119b0f1bdd", size = 788778, upload-time = "2025-10-21T15:55:11.396Z" }, - { url = "https://files.pythonhosted.org/packages/38/f9/70765e63f5ea7d43b2b6cd4ee9d3323f16267e530fb2a420d92d991cf0fc/regex-2025.10.23-cp311-cp311-win32.whl", hash = "sha256:f76deef1f1019a17dad98f408b8f7afc4bd007cbe835ae77b737e8c7f19ae575", size = 265666, upload-time = "2025-10-21T15:55:13.306Z" }, - { url = "https://files.pythonhosted.org/packages/9c/1a/18e9476ee1b63aaec3844d8e1cb21842dc19272c7e86d879bfc0dcc60db3/regex-2025.10.23-cp311-cp311-win_amd64.whl", hash = "sha256:59bba9f7125536f23fdab5deeea08da0c287a64c1d3acc1c7e99515809824de8", size = 277600, upload-time = "2025-10-21T15:55:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1b/c019167b1f7a8ec77251457e3ff0339ed74ca8bce1ea13138dc98309c923/regex-2025.10.23-cp311-cp311-win_arm64.whl", hash = "sha256:b103a752b6f1632ca420225718d6ed83f6a6ced3016dd0a4ab9a6825312de566", size = 269974, upload-time = "2025-10-21T15:55:16.841Z" }, - { url = "https://files.pythonhosted.org/packages/f6/57/eeb274d83ab189d02d778851b1ac478477522a92b52edfa6e2ae9ff84679/regex-2025.10.23-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7a44d9c00f7a0a02d3b777429281376370f3d13d2c75ae74eb94e11ebcf4a7fc", size = 489187, upload-time = "2025-10-21T15:55:18.322Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/7dad43a9b6ea88bf77e0b8b7729a4c36978e1043165034212fd2702880c6/regex-2025.10.23-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b83601f84fde939ae3478bb32a3aef36f61b58c3208d825c7e8ce1a735f143f2", size = 291122, upload-time = "2025-10-21T15:55:20.2Z" }, - { url = "https://files.pythonhosted.org/packages/66/21/38b71e6f2818f0f4b281c8fba8d9d57cfca7b032a648fa59696e0a54376a/regex-2025.10.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec13647907bb9d15fd192bbfe89ff06612e098a5709e7d6ecabbdd8f7908fc45", size = 288797, upload-time = "2025-10-21T15:55:21.932Z" }, - { url = "https://files.pythonhosted.org/packages/be/95/888f069c89e7729732a6d7cca37f76b44bfb53a1e35dda8a2c7b65c1b992/regex-2025.10.23-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d76dd2957d62501084e7012ddafc5fcd406dd982b7a9ca1ea76e8eaaf73e7e", size = 798442, upload-time = "2025-10-21T15:55:23.747Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/4f903c608faf786627a8ee17c06e0067b5acade473678b69c8094b248705/regex-2025.10.23-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8668e5f067e31a47699ebb354f43aeb9c0ef136f915bd864243098524482ac43", size = 864039, upload-time = "2025-10-21T15:55:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/62/19/2df67b526bf25756c7f447dde554fc10a220fd839cc642f50857d01e4a7b/regex-2025.10.23-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a32433fe3deb4b2d8eda88790d2808fed0dc097e84f5e683b4cd4f42edef6cca", size = 912057, upload-time = "2025-10-21T15:55:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d97d73818c642c938db14c0668167f8d39520ca9d983604575ade3fda193afcc", size = 803374, upload-time = "2025-10-21T15:55:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f7/3495151dd3ca79949599b6d069b72a61a2c5e24fc441dccc79dcaf708fe6/regex-2025.10.23-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bca7feecc72ee33579e9f6ddf8babbe473045717a0e7dbc347099530f96e8b9a", size = 787714, upload-time = "2025-10-21T15:55:30.628Z" }, - { url = "https://files.pythonhosted.org/packages/28/65/ee882455e051131869957ee8597faea45188c9a98c0dad724cfb302d4580/regex-2025.10.23-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7e24af51e907d7457cc4a72691ec458320b9ae67dc492f63209f01eecb09de32", size = 858392, upload-time = "2025-10-21T15:55:32.322Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/9287fef5be97529ebd3ac79d256159cb709a07eb58d4be780d1ca3885da8/regex-2025.10.23-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d10bcde58bbdf18146f3a69ec46dd03233b94a4a5632af97aa5378da3a47d288", size = 850484, upload-time = "2025-10-21T15:55:34.037Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b4/b49b88b4fea2f14dc73e5b5842755e782fc2e52f74423d6f4adc130d5880/regex-2025.10.23-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:44383bc0c933388516c2692c9a7503e1f4a67e982f20b9a29d2fb70c6494f147", size = 789634, upload-time = "2025-10-21T15:55:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3c/2f8d199d0e84e78bcd6bdc2be9b62410624f6b796e2893d1837ae738b160/regex-2025.10.23-cp312-cp312-win32.whl", hash = "sha256:6040a86f95438a0114bba16e51dfe27f1bc004fd29fe725f54a586f6d522b079", size = 266060, upload-time = "2025-10-21T15:55:37.902Z" }, - { url = "https://files.pythonhosted.org/packages/d7/67/c35e80969f6ded306ad70b0698863310bdf36aca57ad792f45ddc0e2271f/regex-2025.10.23-cp312-cp312-win_amd64.whl", hash = "sha256:436b4c4352fe0762e3bfa34a5567079baa2ef22aa9c37cf4d128979ccfcad842", size = 276931, upload-time = "2025-10-21T15:55:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/f5/a1/4ed147de7d2b60174f758412c87fa51ada15cd3296a0ff047f4280aaa7ca/regex-2025.10.23-cp312-cp312-win_arm64.whl", hash = "sha256:f4b1b1991617055b46aff6f6db24888c1f05f4db9801349d23f09ed0714a9335", size = 270103, upload-time = "2025-10-21T15:55:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/28/c6/195a6217a43719d5a6a12cc192a22d12c40290cecfa577f00f4fb822f07d/regex-2025.10.23-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b7690f95404a1293923a296981fd943cca12c31a41af9c21ba3edd06398fc193", size = 488956, upload-time = "2025-10-21T15:55:42.887Z" }, - { url = "https://files.pythonhosted.org/packages/4c/93/181070cd1aa2fa541ff2d3afcf763ceecd4937b34c615fa92765020a6c90/regex-2025.10.23-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a32d77aeaea58a13230100dd8797ac1a84c457f3af2fdf0d81ea689d5a9105b", size = 290997, upload-time = "2025-10-21T15:55:44.53Z" }, - { url = "https://files.pythonhosted.org/packages/b6/c5/9d37fbe3a40ed8dda78c23e1263002497540c0d1522ed75482ef6c2000f0/regex-2025.10.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b24b29402f264f70a3c81f45974323b41764ff7159655360543b7cabb73e7d2f", size = 288686, upload-time = "2025-10-21T15:55:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e7/db610ff9f10c2921f9b6ac0c8d8be4681b28ddd40fc0549429366967e61f/regex-2025.10.23-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563824a08c7c03d96856d84b46fdb3bbb7cfbdf79da7ef68725cda2ce169c72a", size = 798466, upload-time = "2025-10-21T15:55:48.24Z" }, - { url = "https://files.pythonhosted.org/packages/90/10/aab883e1fa7fe2feb15ac663026e70ca0ae1411efa0c7a4a0342d9545015/regex-2025.10.23-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0ec8bdd88d2e2659c3518087ee34b37e20bd169419ffead4240a7004e8ed03b", size = 863996, upload-time = "2025-10-21T15:55:50.478Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/8f686dd97a51f3b37d0238cd00a6d0f9ccabe701f05b56de1918571d0d61/regex-2025.10.23-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b577601bfe1d33913fcd9276d7607bbac827c4798d9e14d04bf37d417a6c41cb", size = 912145, upload-time = "2025-10-21T15:55:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ca/639f8cd5b08797bca38fc5e7e07f76641a428cf8c7fca05894caf045aa32/regex-2025.10.23-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9f2c68ac6cb3de94eea08a437a75eaa2bd33f9e97c84836ca0b610a5804368", size = 803370, upload-time = "2025-10-21T15:55:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/a40725bb76959eddf8abc42a967bed6f4851b39f5ac4f20e9794d7832aa5/regex-2025.10.23-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89f8b9ea3830c79468e26b0e21c3585f69f105157c2154a36f6b7839f8afb351", size = 787767, upload-time = "2025-10-21T15:55:56.004Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d8/8ee9858062936b0f99656dce390aa667c6e7fb0c357b1b9bf76fb5e2e708/regex-2025.10.23-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:98fd84c4e4ea185b3bb5bf065261ab45867d8875032f358a435647285c722673", size = 858335, upload-time = "2025-10-21T15:55:58.185Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0a/ed5faaa63fa8e3064ab670e08061fbf09e3a10235b19630cf0cbb9e48c0a/regex-2025.10.23-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1e11d3e5887b8b096f96b4154dfb902f29c723a9556639586cd140e77e28b313", size = 850402, upload-time = "2025-10-21T15:56:00.023Z" }, - { url = "https://files.pythonhosted.org/packages/79/14/d05f617342f4b2b4a23561da500ca2beab062bfcc408d60680e77ecaf04d/regex-2025.10.23-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f13450328a6634348d47a88367e06b64c9d84980ef6a748f717b13f8ce64e87", size = 789739, upload-time = "2025-10-21T15:56:01.967Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7b/e8ce8eef42a15f2c3461f8b3e6e924bbc86e9605cb534a393aadc8d3aff8/regex-2025.10.23-cp313-cp313-win32.whl", hash = "sha256:37be9296598a30c6a20236248cb8b2c07ffd54d095b75d3a2a2ee5babdc51df1", size = 266054, upload-time = "2025-10-21T15:56:05.291Z" }, - { url = "https://files.pythonhosted.org/packages/71/2d/55184ed6be6473187868d2f2e6a0708195fc58270e62a22cbf26028f2570/regex-2025.10.23-cp313-cp313-win_amd64.whl", hash = "sha256:ea7a3c283ce0f06fe789365841e9174ba05f8db16e2fd6ae00a02df9572c04c0", size = 276917, upload-time = "2025-10-21T15:56:07.303Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d4/927eced0e2bd45c45839e556f987f8c8f8683268dd3c00ad327deb3b0172/regex-2025.10.23-cp313-cp313-win_arm64.whl", hash = "sha256:d9a4953575f300a7bab71afa4cd4ac061c7697c89590a2902b536783eeb49a4f", size = 270105, upload-time = "2025-10-21T15:56:09.857Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b3/95b310605285573341fc062d1d30b19a54f857530e86c805f942c4ff7941/regex-2025.10.23-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7d6606524fa77b3912c9ef52a42ef63c6cfbfc1077e9dc6296cd5da0da286044", size = 491850, upload-time = "2025-10-21T15:56:11.685Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8f/207c2cec01e34e56db1eff606eef46644a60cf1739ecd474627db90ad90b/regex-2025.10.23-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c037aadf4d64bdc38af7db3dbd34877a057ce6524eefcb2914d6d41c56f968cc", size = 292537, upload-time = "2025-10-21T15:56:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/98/3b/025240af4ada1dc0b5f10d73f3e5122d04ce7f8908ab8881e5d82b9d61b6/regex-2025.10.23-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:99018c331fb2529084a0c9b4c713dfa49fafb47c7712422e49467c13a636c656", size = 290904, upload-time = "2025-10-21T15:56:16.016Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/104ac14e2d3450c43db18ec03e1b96b445a94ae510b60138f00ce2cb7ca1/regex-2025.10.23-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd8aba965604d70306eb90a35528f776e59112a7114a5162824d43b76fa27f58", size = 807311, upload-time = "2025-10-21T15:56:17.818Z" }, - { url = "https://files.pythonhosted.org/packages/19/63/78aef90141b7ce0be8a18e1782f764f6997ad09de0e05251f0d2503a914a/regex-2025.10.23-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:238e67264b4013e74136c49f883734f68656adf8257bfa13b515626b31b20f8e", size = 873241, upload-time = "2025-10-21T15:56:19.941Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a8/80eb1201bb49ae4dba68a1b284b4211ed9daa8e74dc600018a10a90399fb/regex-2025.10.23-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2eb48bd9848d66fd04826382f5e8491ae633de3233a3d64d58ceb4ecfa2113a", size = 914794, upload-time = "2025-10-21T15:56:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d5/1984b6ee93281f360a119a5ca1af6a8ca7d8417861671388bf750becc29b/regex-2025.10.23-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d36591ce06d047d0c0fe2fc5f14bfbd5b4525d08a7b6a279379085e13f0e3d0e", size = 812581, upload-time = "2025-10-21T15:56:24.319Z" }, - { url = "https://files.pythonhosted.org/packages/c4/39/11ebdc6d9927172a64ae237d16763145db6bd45ebb4055c17b88edab72a7/regex-2025.10.23-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5d4ece8628d6e364302006366cea3ee887db397faebacc5dacf8ef19e064cf8", size = 795346, upload-time = "2025-10-21T15:56:26.232Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b4/89a591bcc08b5e436af43315284bd233ba77daf0cf20e098d7af12f006c1/regex-2025.10.23-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:39a7e8083959cb1c4ff74e483eecb5a65d3b3e1d821b256e54baf61782c906c6", size = 868214, upload-time = "2025-10-21T15:56:28.597Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/58ba98409c1dbc8316cdb20dafbc63ed267380a07780cafecaf5012dabc9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:842d449a8fefe546f311656cf8c0d6729b08c09a185f1cad94c756210286d6a8", size = 854540, upload-time = "2025-10-21T15:56:30.875Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f2/4a9e9338d67626e2071b643f828a482712ad15889d7268e11e9a63d6f7e9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d614986dc68506be8f00474f4f6960e03e4ca9883f7df47744800e7d7c08a494", size = 799346, upload-time = "2025-10-21T15:56:32.725Z" }, - { url = "https://files.pythonhosted.org/packages/63/be/543d35c46bebf6f7bf2be538cca74d6585f25714700c36f37f01b92df551/regex-2025.10.23-cp313-cp313t-win32.whl", hash = "sha256:a5b7a26b51a9df473ec16a1934d117443a775ceb7b39b78670b2e21893c330c9", size = 268657, upload-time = "2025-10-21T15:56:34.577Z" }, - { url = "https://files.pythonhosted.org/packages/14/9f/4dd6b7b612037158bb2c9bcaa710e6fb3c40ad54af441b9c53b3a137a9f1/regex-2025.10.23-cp313-cp313t-win_amd64.whl", hash = "sha256:ce81c5544a5453f61cb6f548ed358cfb111e3b23f3cd42d250a4077a6be2a7b6", size = 280075, upload-time = "2025-10-21T15:56:36.767Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/5bd0672aa65d38c8da6747c17c8b441bdb53d816c569e3261013af8e83cf/regex-2025.10.23-cp313-cp313t-win_arm64.whl", hash = "sha256:e9bf7f6699f490e4e43c44757aa179dab24d1960999c84ab5c3d5377714ed473", size = 271219, upload-time = "2025-10-21T15:56:39.033Z" }, - { url = "https://files.pythonhosted.org/packages/73/f6/0caf29fec943f201fbc8822879c99d31e59c1d51a983d9843ee5cf398539/regex-2025.10.23-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5b5cb5b6344c4c4c24b2dc87b0bfee78202b07ef7633385df70da7fcf6f7cec6", size = 488960, upload-time = "2025-10-21T15:56:40.849Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7d/ebb7085b8fa31c24ce0355107cea2b92229d9050552a01c5d291c42aecea/regex-2025.10.23-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a6ce7973384c37bdf0f371a843f95a6e6f4e1489e10e0cf57330198df72959c5", size = 290932, upload-time = "2025-10-21T15:56:42.875Z" }, - { url = "https://files.pythonhosted.org/packages/27/41/43906867287cbb5ca4cee671c3cc8081e15deef86a8189c3aad9ac9f6b4d/regex-2025.10.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ee3663f2c334959016b56e3bd0dd187cbc73f948e3a3af14c3caaa0c3035d10", size = 288766, upload-time = "2025-10-21T15:56:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9e/ea66132776700fc77a39b1056e7a5f1308032fead94507e208dc6716b7cd/regex-2025.10.23-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2003cc82a579107e70d013482acce8ba773293f2db534fb532738395c557ff34", size = 798884, upload-time = "2025-10-21T15:56:47.178Z" }, - { url = "https://files.pythonhosted.org/packages/d5/99/aed1453687ab63819a443930770db972c5c8064421f0d9f5da9ad029f26b/regex-2025.10.23-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:182c452279365a93a9f45874f7f191ec1c51e1f1eb41bf2b16563f1a40c1da3a", size = 864768, upload-time = "2025-10-21T15:56:49.793Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/732fe747a1304805eb3853ce6337eea16b169f7105a0d0dd9c6a5ffa9948/regex-2025.10.23-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b1249e9ff581c5b658c8f0437f883b01f1edcf424a16388591e7c05e5e9e8b0c", size = 911394, upload-time = "2025-10-21T15:56:52.186Z" }, - { url = "https://files.pythonhosted.org/packages/5e/48/58a1f6623466522352a6efa153b9a3714fc559d9f930e9bc947b4a88a2c3/regex-2025.10.23-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b841698f93db3ccc36caa1900d2a3be281d9539b822dc012f08fc80b46a3224", size = 803145, upload-time = "2025-10-21T15:56:55.142Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f6/7dea79be2681a5574ab3fc237aa53b2c1dfd6bd2b44d4640b6c76f33f4c1/regex-2025.10.23-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:956d89e0c92d471e8f7eee73f73fdff5ed345886378c45a43175a77538a1ffe4", size = 787831, upload-time = "2025-10-21T15:56:57.203Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ad/07b76950fbbe65f88120ca2d8d845047c401450f607c99ed38862904671d/regex-2025.10.23-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5c259cb363299a0d90d63b5c0d7568ee98419861618a95ee9d91a41cb9954462", size = 859162, upload-time = "2025-10-21T15:56:59.195Z" }, - { url = "https://files.pythonhosted.org/packages/41/87/374f3b2021b22aa6a4fc0b750d63f9721e53d1631a238f7a1c343c1cd288/regex-2025.10.23-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:185d2b18c062820b3a40d8fefa223a83f10b20a674bf6e8c4a432e8dfd844627", size = 849899, upload-time = "2025-10-21T15:57:01.747Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/7f7bb17c5a5a9747249807210e348450dab9212a46ae6d23ebce86ba6a2b/regex-2025.10.23-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:281d87fa790049c2b7c1b4253121edd80b392b19b5a3d28dc2a77579cb2a58ec", size = 789372, upload-time = "2025-10-21T15:57:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/9c7728ff544fea09bbc8635e4c9e7c423b11c24f1a7a14e6ac4831466709/regex-2025.10.23-cp314-cp314-win32.whl", hash = "sha256:63b81eef3656072e4ca87c58084c7a9c2b81d41a300b157be635a8a675aacfb8", size = 271451, upload-time = "2025-10-21T15:57:06.266Z" }, - { url = "https://files.pythonhosted.org/packages/48/f8/ef7837ff858eb74079c4804c10b0403c0b740762e6eedba41062225f7117/regex-2025.10.23-cp314-cp314-win_amd64.whl", hash = "sha256:0967c5b86f274800a34a4ed862dfab56928144d03cb18821c5153f8777947796", size = 280173, upload-time = "2025-10-21T15:57:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d0/d576e1dbd9885bfcd83d0e90762beea48d9373a6f7ed39170f44ed22e336/regex-2025.10.23-cp314-cp314-win_arm64.whl", hash = "sha256:c70dfe58b0a00b36aa04cdb0f798bf3e0adc31747641f69e191109fd8572c9a9", size = 273206, upload-time = "2025-10-21T15:57:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d0/2025268315e8b2b7b660039824cb7765a41623e97d4cd421510925400487/regex-2025.10.23-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1f5799ea1787aa6de6c150377d11afad39a38afd033f0c5247aecb997978c422", size = 491854, upload-time = "2025-10-21T15:57:12.526Z" }, - { url = "https://files.pythonhosted.org/packages/44/35/5681c2fec5e8b33454390af209c4353dfc44606bf06d714b0b8bd0454ffe/regex-2025.10.23-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a9639ab7540cfea45ef57d16dcbea2e22de351998d614c3ad2f9778fa3bdd788", size = 292542, upload-time = "2025-10-21T15:57:15.158Z" }, - { url = "https://files.pythonhosted.org/packages/5d/17/184eed05543b724132e4a18149e900f5189001fcfe2d64edaae4fbaf36b4/regex-2025.10.23-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08f52122c352eb44c3421dab78b9b73a8a77a282cc8314ae576fcaa92b780d10", size = 290903, upload-time = "2025-10-21T15:57:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/25/d0/5e3347aa0db0de382dddfa133a7b0ae72f24b4344f3989398980b44a3924/regex-2025.10.23-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebf1baebef1c4088ad5a5623decec6b52950f0e4d7a0ae4d48f0a99f8c9cb7d7", size = 807546, upload-time = "2025-10-21T15:57:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/40c589bbdce1be0c55e9f8159789d58d47a22014f2f820cf2b517a5cd193/regex-2025.10.23-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:16b0f1c2e2d566c562d5c384c2b492646be0a19798532fdc1fdedacc66e3223f", size = 873322, upload-time = "2025-10-21T15:57:21.36Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a7e40c01575ac93360e606278d359f91829781a9f7fb6e5aa435039edbda/regex-2025.10.23-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7ada5d9dceafaab92646aa00c10a9efd9b09942dd9b0d7c5a4b73db92cc7e61", size = 914855, upload-time = "2025-10-21T15:57:24.044Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4b/d55587b192763db3163c3f508b3b67b31bb6f5e7a0e08b83013d0a59500a/regex-2025.10.23-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a36b4005770044bf08edecc798f0e41a75795b9e7c9c12fe29da8d792ef870c", size = 812724, upload-time = "2025-10-21T15:57:26.123Z" }, - { url = "https://files.pythonhosted.org/packages/33/20/18bac334955fbe99d17229f4f8e98d05e4a501ac03a442be8facbb37c304/regex-2025.10.23-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:af7b2661dcc032da1fae82069b5ebf2ac1dfcd5359ef8b35e1367bfc92181432", size = 795439, upload-time = "2025-10-21T15:57:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/67/46/c57266be9df8549c7d85deb4cb82280cb0019e46fff677534c5fa1badfa4/regex-2025.10.23-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb976810ac1416a67562c2e5ba0accf6f928932320fef302e08100ed681b38e", size = 868336, upload-time = "2025-10-21T15:57:30.867Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f3/bd5879e41ef8187fec5e678e94b526a93f99e7bbe0437b0f2b47f9101694/regex-2025.10.23-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:1a56a54be3897d62f54290190fbcd754bff6932934529fbf5b29933da28fcd43", size = 854567, upload-time = "2025-10-21T15:57:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/e6/57/2b6bbdbd2f24dfed5b028033aa17ad8f7d86bb28f1a892cac8b3bc89d059/regex-2025.10.23-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f3e6d202fb52c2153f532043bbcf618fd177df47b0b306741eb9b60ba96edc3", size = 799565, upload-time = "2025-10-21T15:57:35.153Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ba/a6168f542ba73b151ed81237adf6b869c7b2f7f8d51618111296674e20ee/regex-2025.10.23-cp314-cp314t-win32.whl", hash = "sha256:1fa1186966b2621b1769fd467c7b22e317e6ba2d2cdcecc42ea3089ef04a8521", size = 274428, upload-time = "2025-10-21T15:57:37.996Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/c84475e14a2829e9b0864ebf77c3f7da909df9d8acfe2bb540ff0072047c/regex-2025.10.23-cp314-cp314t-win_amd64.whl", hash = "sha256:08a15d40ce28362eac3e78e83d75475147869c1ff86bc93285f43b4f4431a741", size = 284140, upload-time = "2025-10-21T15:57:40.027Z" }, - { url = "https://files.pythonhosted.org/packages/51/33/6a08ade0eee5b8ba79386869fa6f77afeb835b60510f3525db987e2fffc4/regex-2025.10.23-cp314-cp314t-win_arm64.whl", hash = "sha256:a93e97338e1c8ea2649e130dcfbe8cd69bba5e1e163834752ab64dcb4de6d5ed", size = 274497, upload-time = "2025-10-21T15:57:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/d788d52da01280a30a3f6268aef2aa71043bff359c618fea4c5b536654d5/regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af", size = 488087, upload-time = "2025-11-03T21:30:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/abec3bd688ec9bbea3562de0fd764ff802976185f5ff22807bf0a2697992/regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313", size = 290544, upload-time = "2025-11-03T21:30:49.912Z" }, + { url = "https://files.pythonhosted.org/packages/39/b3/9a231475d5653e60002508f41205c61684bb2ffbf2401351ae2186897fc4/regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56", size = 288408, upload-time = "2025-11-03T21:30:51.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c5/1929a0491bd5ac2d1539a866768b88965fa8c405f3e16a8cef84313098d6/regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28", size = 781584, upload-time = "2025-11-03T21:30:52.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/16aa16cf5d497ef727ec966f74164fbe75d6516d3d58ac9aa989bc9cdaad/regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7", size = 850733, upload-time = "2025-11-03T21:30:53.825Z" }, + { url = "https://files.pythonhosted.org/packages/e6/49/3294b988855a221cb6565189edf5dc43239957427df2d81d4a6b15244f64/regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32", size = 898691, upload-time = "2025-11-03T21:30:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/14/62/b56d29e70b03666193369bdbdedfdc23946dbe9f81dd78ce262c74d988ab/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391", size = 791662, upload-time = "2025-11-03T21:30:57.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/fc/e4c31d061eced63fbf1ce9d853975f912c61a7d406ea14eda2dd355f48e7/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5", size = 782587, upload-time = "2025-11-03T21:30:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bb/5e30c7394bcf63f0537121c23e796be67b55a8847c3956ae6068f4c70702/regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7", size = 774709, upload-time = "2025-11-03T21:31:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c4/fce773710af81b0cb37cb4ff0947e75d5d17dee304b93d940b87a67fc2f4/regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313", size = 845773, upload-time = "2025-11-03T21:31:01.583Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/9466a7ec4b8ec282077095c6eb50a12a389d2e036581134d4919e8ca518c/regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9", size = 836164, upload-time = "2025-11-03T21:31:03.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/82980a60e8ed1594eb3c89eb814fb276ef51b9af7caeab1340bfd8564af6/regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5", size = 779832, upload-time = "2025-11-03T21:31:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/90ab0fdbe6dce064a42015433f9152710139fb04a8b81b4fb57a1cb63ffa/regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec", size = 265802, upload-time = "2025-11-03T21:31:06.581Z" }, + { url = "https://files.pythonhosted.org/packages/34/9d/e9e8493a85f3b1ddc4a5014465f5c2b78c3ea1cbf238dcfde78956378041/regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd", size = 277722, upload-time = "2025-11-03T21:31:08.144Z" }, + { url = "https://files.pythonhosted.org/packages/15/c4/b54b24f553966564506dbf873a3e080aef47b356a3b39b5d5aba992b50db/regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e", size = 270289, upload-time = "2025-11-03T21:31:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" }, + { url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" }, + { url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" }, + { url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" }, + { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" }, + { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" }, + { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" }, + { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" }, + { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" }, + { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, + { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, + { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, + { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, + { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, + { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, + { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, ] [[package]] @@ -5199,28 +5255,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.2" +version = "0.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, - { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, - { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, - { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, - { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, - { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, - { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, ] [[package]] @@ -5714,14 +5770,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "0.49.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, ] [[package]]