chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework DevUI - Debug interface with OpenAI compatible API server."""
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ._conversations import CheckpointConversationManager
|
||||
from ._server import DevServer
|
||||
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
|
||||
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cleanup registry (before serve() is called)
|
||||
_cleanup_registry: dict[int, list[Callable[[], Any]]] = {}
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
|
||||
def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
|
||||
"""Register cleanup hook(s) for an entity.
|
||||
|
||||
Cleanup hooks execute during DevUI server shutdown, before entity
|
||||
clients are closed. Supports both synchronous and asynchronous callables.
|
||||
|
||||
Args:
|
||||
entity: Agent, workflow, or other entity object
|
||||
*hooks: One or more cleanup callables (sync or async)
|
||||
|
||||
Raises:
|
||||
ValueError: If no hooks provided
|
||||
|
||||
Examples:
|
||||
Single cleanup hook:
|
||||
>>> from agent_framework.devui import serve, register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = Agent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> serve(entities=[agent])
|
||||
|
||||
Multiple cleanup hooks:
|
||||
>>> register_cleanup(agent, credential.close, session.close, db_pool.close)
|
||||
|
||||
Works with file-based discovery:
|
||||
>>> # In agents/my_agent/agent.py
|
||||
>>> from agent_framework.devui import register_cleanup
|
||||
>>> credential = DefaultAzureCredential()
|
||||
>>> agent = Agent(...)
|
||||
>>> register_cleanup(agent, credential.close)
|
||||
>>> # Run: devui ./agents
|
||||
"""
|
||||
if not hooks:
|
||||
raise ValueError("At least one cleanup hook required")
|
||||
|
||||
# Use id() to track entity identity (works across modules)
|
||||
entity_id = id(entity)
|
||||
|
||||
if entity_id not in _cleanup_registry:
|
||||
_cleanup_registry[entity_id] = []
|
||||
|
||||
_cleanup_registry[entity_id].extend(hooks)
|
||||
|
||||
logger.debug(
|
||||
f"Registered {len(hooks)} cleanup hook(s) for {type(entity).__name__} "
|
||||
f"(id: {entity_id}, total: {len(_cleanup_registry[entity_id])})"
|
||||
)
|
||||
|
||||
|
||||
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]: # type: ignore[reportUnusedFunction]
|
||||
"""Get cleanup hooks registered for an entity (internal use).
|
||||
|
||||
Args:
|
||||
entity: Entity object to get hooks for
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks registered for the entity
|
||||
"""
|
||||
entity_id = id(entity)
|
||||
return _cleanup_registry.get(entity_id, [])
|
||||
|
||||
|
||||
def serve(
|
||||
entities: list[Any] | None = None,
|
||||
entities_dir: str | None = None,
|
||||
port: int = 8080,
|
||||
host: str = "127.0.0.1",
|
||||
auto_open: bool = False,
|
||||
cors_origins: list[str] | None = None,
|
||||
ui_enabled: bool = True,
|
||||
instrumentation_enabled: bool = False,
|
||||
mode: str = "developer",
|
||||
auth_enabled: bool = True,
|
||||
auth_token: str | None = None,
|
||||
) -> None:
|
||||
"""Launch Agent Framework DevUI with simple API.
|
||||
|
||||
Args:
|
||||
entities: List of entities for in-memory registration (IDs auto-generated)
|
||||
entities_dir: Directory to scan for entities
|
||||
port: Port to run server on
|
||||
host: Host to bind server to
|
||||
auto_open: Whether to automatically open browser
|
||||
cors_origins: List of allowed CORS origins
|
||||
ui_enabled: Whether to enable the UI
|
||||
instrumentation_enabled: Whether to enable OpenTelemetry instrumentation
|
||||
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
|
||||
auth_enabled: Whether to enable Bearer token authentication
|
||||
auth_token: Custom authentication token (auto-generated if not provided with auth_enabled=True)
|
||||
"""
|
||||
import re
|
||||
|
||||
import uvicorn
|
||||
|
||||
# Validate host parameter early for security
|
||||
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
|
||||
raise ValueError(f"Invalid host: {host}. Must be localhost, IP address, or valid hostname")
|
||||
|
||||
# Validate port parameter
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
|
||||
|
||||
# Enable instrumentation if requested
|
||||
if instrumentation_enabled:
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
|
||||
enable_instrumentation(enable_sensitive_data=True)
|
||||
logger.info("Enabled Agent Framework instrumentation with sensitive data")
|
||||
|
||||
# Create server with direct parameters
|
||||
server = DevServer(
|
||||
entities_dir=entities_dir,
|
||||
port=port,
|
||||
host=host,
|
||||
cors_origins=cors_origins,
|
||||
ui_enabled=ui_enabled,
|
||||
mode=mode,
|
||||
auth_enabled=auth_enabled,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
|
||||
# Register in-memory entities if provided
|
||||
if entities:
|
||||
logger.info(f"Registering {len(entities)} in-memory entities")
|
||||
# Store entities for later registration during server startup
|
||||
server.set_pending_entities(entities)
|
||||
|
||||
app = server.get_app()
|
||||
|
||||
if auto_open:
|
||||
|
||||
def open_browser() -> None:
|
||||
import http.client
|
||||
import re
|
||||
import time
|
||||
|
||||
# Validate host and port for security
|
||||
if not re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|[a-zA-Z0-9.-]+)$", host):
|
||||
logger.warning(f"Invalid host for auto-open: {host}")
|
||||
return
|
||||
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
logger.warning(f"Invalid port for auto-open: {port}")
|
||||
return
|
||||
|
||||
# Wait for server to be ready by checking health endpoint
|
||||
browser_url = f"http://{host}:{port}"
|
||||
|
||||
for _ in range(30): # 15 second timeout (30 * 0.5s)
|
||||
try:
|
||||
# Use http.client for safe connection handling (standard library)
|
||||
conn = http.client.HTTPConnection(host, port, timeout=1)
|
||||
try:
|
||||
conn.request("GET", "/health")
|
||||
response = conn.getresponse()
|
||||
if response.status == 200:
|
||||
webbrowser.open(browser_url)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
except (http.client.HTTPException, OSError, TimeoutError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# Fallback: open browser anyway after timeout
|
||||
webbrowser.open(browser_url)
|
||||
|
||||
import threading
|
||||
|
||||
threading.Thread(target=open_browser, daemon=True).start()
|
||||
|
||||
logger.info(f"Starting Agent Framework DevUI on {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for devui command."""
|
||||
from ._cli import main as cli_main
|
||||
|
||||
cli_main()
|
||||
|
||||
|
||||
# Export main public API
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"CheckpointConversationManager",
|
||||
"DevServer",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"EnvVarRequirement",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"ResponseStreamEvent",
|
||||
"main",
|
||||
"register_cleanup",
|
||||
"serve",
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Command line interface for Agent Framework DevUI."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""Configure logging for the server."""
|
||||
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=getattr(logging, level.upper()), format=log_format, datefmt="%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def create_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create the command line argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="devui",
|
||||
description="Launch Agent Framework DevUI - Debug interface with OpenAI compatible API",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
devui # Scan current directory
|
||||
devui ./agents # Scan specific directory
|
||||
devui --port 8000 # Custom port
|
||||
devui --headless # API only, no UI
|
||||
devui --instrumentation # Enable OpenTelemetry instrumentation
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"directory", nargs="?", default=".", help="Directory to scan for entities (default: current directory)"
|
||||
)
|
||||
|
||||
parser.add_argument("--port", "-p", type=int, default=8080, help="Port to run server on (default: 8080)")
|
||||
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind server to (default: 127.0.0.1)")
|
||||
|
||||
parser.add_argument("--no-open", action="store_true", help="Don't automatically open browser")
|
||||
|
||||
parser.add_argument("--headless", action="store_true", help="Run without UI (API only)")
|
||||
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
default="INFO",
|
||||
help="Logging level (default: INFO)",
|
||||
)
|
||||
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
|
||||
|
||||
parser.add_argument("--instrumentation", action="store_true", help="Enable OpenTelemetry instrumentation")
|
||||
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["developer", "user"],
|
||||
default=None,
|
||||
help="Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)",
|
||||
)
|
||||
|
||||
# Add --dev/--no-dev as a convenient alternative to --mode
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="dev_mode",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Enable developer mode (shorthand for --mode developer)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-dev",
|
||||
dest="dev_mode",
|
||||
action="store_false",
|
||||
help="Disable developer mode (shorthand for --mode user)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-auth",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
type=str,
|
||||
help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.",
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get the package version."""
|
||||
try:
|
||||
from . import __version__
|
||||
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def validate_directory(directory: str) -> str:
|
||||
"""Validate and normalize the entities directory."""
|
||||
if not directory:
|
||||
directory = "."
|
||||
|
||||
abs_dir = os.path.abspath(directory)
|
||||
|
||||
if not os.path.exists(abs_dir):
|
||||
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(abs_dir):
|
||||
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
return abs_dir
|
||||
|
||||
|
||||
def print_startup_info(
|
||||
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
|
||||
) -> None:
|
||||
"""Print startup information."""
|
||||
print("Agent Framework DevUI") # noqa: T201
|
||||
print("=" * 50) # noqa: T201
|
||||
print(f"Entities directory: {entities_dir}") # noqa: T201
|
||||
print(f"Server URL: http://{host}:{port}") # noqa: T201
|
||||
print(f"UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
|
||||
print(f"Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
|
||||
|
||||
# Display auth token if authentication is enabled
|
||||
if auth_token:
|
||||
print("Authentication: Enabled") # noqa: T201
|
||||
print(f"Auth token: {auth_token}") # noqa: T201
|
||||
print("💡 Use this token in Authorization: Bearer <token> header") # noqa: T201
|
||||
|
||||
print("=" * 50) # noqa: T201
|
||||
print("Scanning for entities...") # noqa: T201
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
parser = create_cli_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
setup_logging(args.log_level)
|
||||
|
||||
# Validate directory
|
||||
entities_dir = validate_directory(args.directory)
|
||||
|
||||
# Extract parameters directly from args
|
||||
ui_enabled = not args.headless
|
||||
|
||||
# Determine mode from --mode or --dev/--no-dev flags
|
||||
if args.dev_mode is not None:
|
||||
# --dev or --no-dev was specified
|
||||
mode = "developer" if args.dev_mode else "user"
|
||||
elif args.mode is not None:
|
||||
# --mode was specified
|
||||
mode = args.mode
|
||||
else:
|
||||
# Default to developer mode
|
||||
mode = "developer"
|
||||
|
||||
# Print startup info (don't show token - serve() will handle it)
|
||||
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload, None)
|
||||
|
||||
# Import and start server
|
||||
try:
|
||||
from . import serve
|
||||
|
||||
serve(
|
||||
entities_dir=entities_dir,
|
||||
port=args.port,
|
||||
host=args.host,
|
||||
auto_open=not args.no_open,
|
||||
ui_enabled=ui_enabled,
|
||||
instrumentation_enabled=args.instrumentation,
|
||||
mode=mode,
|
||||
auth_enabled=not args.no_auth,
|
||||
auth_token=args.auth_token, # Pass through explicit token only
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down Agent Framework DevUI...") # noqa: T201
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start server")
|
||||
print(f"Error: {e}", file=sys.stderr) # noqa: T201
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,717 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Conversation storage abstraction for OpenAI Conversations API.
|
||||
|
||||
This module provides a clean abstraction layer for managing conversations
|
||||
with in-memory message storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import MutableSequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import AgentSession, Message
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.conversations.message import Content as OpenAIContent
|
||||
from openai.types.conversations.message import Message as OpenAIMessage
|
||||
from openai.types.conversations.text_content import TextContent
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputFile,
|
||||
ResponseInputImage,
|
||||
)
|
||||
|
||||
# Type alias for OpenAI Message role literals
|
||||
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
|
||||
|
||||
# Checkpoint item type constants
|
||||
CONVERSATION_ITEM_TYPE_CHECKPOINT = "checkpoint"
|
||||
CONVERSATION_TYPE_CHECKPOINT_CONTAINER = "checkpoint_container"
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""Abstract base class for conversation storage.
|
||||
|
||||
Provides OpenAI Conversations API interface while managing
|
||||
message storage internally.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation.
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
|
||||
conversation_id: Optional conversation ID (if None, generates one)
|
||||
|
||||
Returns:
|
||||
Conversation object with generated or provided ID
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Conversation object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
metadata: New metadata dict
|
||||
|
||||
Returns:
|
||||
Updated Conversation object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
ConversationDeletedResource object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
items: List of conversation items to add
|
||||
|
||||
Returns:
|
||||
List of added ConversationItem objects
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
limit: Maximum number of items to return
|
||||
after: Cursor for pagination (item_id)
|
||||
order: Sort order ("asc" or "desc")
|
||||
|
||||
Returns:
|
||||
Tuple of (items list, has_more boolean)
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get a specific conversation item by ID.
|
||||
|
||||
Supports checkpoint items - will load full checkpoint state from storage.
|
||||
For checkpoints, the full state is included in metadata.full_checkpoint.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
ConversationItem or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self, conversation_id: str) -> AgentSession | None:
|
||||
"""Get AgentSession for agent execution.
|
||||
|
||||
This is the critical method that allows the executor to get the
|
||||
AgentSession for running agents with conversation context.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
AgentSession object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id).
|
||||
|
||||
Args:
|
||||
metadata_filter: Metadata key-value pairs to match
|
||||
|
||||
Returns:
|
||||
List of matching Conversation objects
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
|
||||
"""Add a trace event to the conversation for context inspection.
|
||||
|
||||
Traces capture execution metadata like token usage, timing, and LLM context
|
||||
that is useful for debugging.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
trace_event: Trace event data (from ResponseTraceEvent.data)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all trace events for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
List of trace event dicts, or empty list if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class InMemoryConversationStore(ConversationStore):
|
||||
"""In-memory conversation storage.
|
||||
|
||||
This implementation stores conversations in memory with their
|
||||
underlying message lists and AgentSession instances for execution.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize in-memory conversation storage.
|
||||
|
||||
Storage structure maps conversation IDs to conversation data including
|
||||
messages, metadata, and cached ConversationItems.
|
||||
"""
|
||||
self._conversations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}}
|
||||
self._item_index: dict[str, dict[str, ConversationItem]] = {}
|
||||
|
||||
def create_conversation(
|
||||
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
|
||||
) -> Conversation:
|
||||
"""Create a new conversation with message storage and checkpoint storage."""
|
||||
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Create message list for internal storage and AgentSession for execution
|
||||
messages: list[Message] = []
|
||||
session = AgentSession(session_id=conv_id)
|
||||
|
||||
# Create session-scoped checkpoint storage (one per conversation)
|
||||
checkpoint_storage = InMemoryCheckpointStorage()
|
||||
|
||||
self._conversations[conv_id] = {
|
||||
"id": conv_id,
|
||||
"messages": messages,
|
||||
"session": session,
|
||||
"checkpoint_storage": checkpoint_storage,
|
||||
"metadata": metadata or {},
|
||||
"created_at": created_at,
|
||||
"items": [],
|
||||
"traces": [], # Trace events for context inspection (token usage, timing, etc.)
|
||||
}
|
||||
|
||||
# Initialize item index for this conversation
|
||||
self._item_index[conv_id] = {}
|
||||
|
||||
return Conversation(id=conv_id, object="conversation", created_at=created_at, metadata=metadata)
|
||||
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
return None
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_data.get("metadata"),
|
||||
)
|
||||
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
conv_data["metadata"] = metadata
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation."""
|
||||
if conversation_id not in self._conversations:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
del self._conversations[conversation_id]
|
||||
# Cleanup item index
|
||||
self._item_index.pop(conversation_id, None)
|
||||
|
||||
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
|
||||
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
stored_messages: list[Message] = conv_data["messages"]
|
||||
|
||||
# Convert items to Messages and add to storage
|
||||
chat_messages: list[Message] = []
|
||||
for item in items:
|
||||
# Simple conversion - assume text content for now
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", [])
|
||||
first_content = cast(
|
||||
dict[str, Any],
|
||||
content[0] if content and isinstance(content, list) and isinstance(content[0], dict) else {},
|
||||
)
|
||||
text_obj = first_content.get("text", "")
|
||||
text = text_obj if isinstance(text_obj, str) else str(text_obj)
|
||||
|
||||
chat_msg = Message(role=role, contents=[text])
|
||||
chat_messages.append(chat_msg)
|
||||
|
||||
# Add messages to internal storage
|
||||
stored_messages.extend(chat_messages)
|
||||
|
||||
# Create Message objects (ConversationItem is a Union - use concrete Message type)
|
||||
conv_items: list[ConversationItem] = []
|
||||
for msg in chat_messages:
|
||||
item_id = f"item_{uuid.uuid4().hex}"
|
||||
|
||||
# Convert Message contents to OpenAI TextContent format
|
||||
message_content: MutableSequence[OpenAIContent] = []
|
||||
for content_item in msg.contents:
|
||||
if content_item.type == "text":
|
||||
# Extract text from TextContent object
|
||||
message_content.append(TextContent(type="text", text=content_item.text or ""))
|
||||
|
||||
# Create Message object (concrete type from ConversationItem union)
|
||||
message = OpenAIMessage(
|
||||
id=item_id,
|
||||
type="message", # Required discriminator for union
|
||||
role=cast(MessageRole, msg.role), # Safe: Agent Framework roles match OpenAI roles,
|
||||
content=message_content,
|
||||
status="completed", # Required field
|
||||
)
|
||||
conv_items.append(message)
|
||||
|
||||
# Cache items
|
||||
conv_data["items"].extend(conv_items)
|
||||
|
||||
# Update item index for O(1) lookup
|
||||
if conversation_id not in self._item_index:
|
||||
self._item_index[conversation_id] = {}
|
||||
|
||||
for conv_item in conv_items:
|
||||
if conv_item.id: # Guard against None
|
||||
self._item_index[conversation_id][conv_item.id] = conv_item
|
||||
|
||||
return conv_items
|
||||
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items.
|
||||
|
||||
Converts stored Messages to proper OpenAI ConversationItem types:
|
||||
- Messages with text/images/files → Message
|
||||
- Function calls → ResponseFunctionToolCallItem
|
||||
- Function results → ResponseFunctionToolCallOutputItem
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
stored_messages: list[Message] = conv_data["messages"]
|
||||
|
||||
# Convert stored messages to ConversationItem types
|
||||
items: list[ConversationItem] = []
|
||||
af_messages = stored_messages
|
||||
|
||||
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
|
||||
for i, msg in enumerate(af_messages):
|
||||
item_id = f"item_{i}"
|
||||
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
|
||||
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
|
||||
|
||||
# Process each content item in the message
|
||||
# A single Message may produce multiple ConversationItems
|
||||
# (e.g., a message with both text and a function call)
|
||||
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
|
||||
function_calls: list[ResponseFunctionToolCallItem] = []
|
||||
function_results: list[ResponseFunctionToolCallOutputItem] = []
|
||||
|
||||
for content in msg.contents:
|
||||
content_type = getattr(content, "type", None)
|
||||
|
||||
if content_type == "text":
|
||||
# Text content for Message
|
||||
text_value = getattr(content, "text", "")
|
||||
message_contents.append(TextContent(type="text", text=text_value))
|
||||
|
||||
elif content_type == "data":
|
||||
# Data content (images, files, PDFs)
|
||||
uri = getattr(content, "uri", "")
|
||||
media_type = getattr(content, "media_type", None)
|
||||
|
||||
if media_type and media_type.startswith("image/"):
|
||||
# Convert to ResponseInputImage
|
||||
message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto"))
|
||||
else:
|
||||
# Convert to ResponseInputFile
|
||||
# Extract filename from URI if possible
|
||||
filename = None
|
||||
if media_type == "application/pdf":
|
||||
filename = "document.pdf"
|
||||
|
||||
message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename))
|
||||
|
||||
elif content_type == "function_call":
|
||||
# Function call - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
name = getattr(content, "name", "")
|
||||
arguments = getattr(content, "arguments", "")
|
||||
|
||||
if call_id and name:
|
||||
function_calls.append(
|
||||
ResponseFunctionToolCallItem(
|
||||
id=f"{item_id}_call_{call_id}",
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
type="function_call",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
elif content_type == "function_result":
|
||||
# Function result - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
# Output is stored in the 'result' field of FunctionResultContent
|
||||
result_value = getattr(content, "result", None)
|
||||
# Convert result to string (it could be dict, list, or other types)
|
||||
if result_value is None:
|
||||
output = ""
|
||||
elif isinstance(result_value, str):
|
||||
output = result_value
|
||||
else:
|
||||
import json
|
||||
|
||||
try:
|
||||
output = json.dumps(result_value)
|
||||
except (TypeError, ValueError):
|
||||
output = str(result_value)
|
||||
|
||||
if call_id:
|
||||
function_results.append(
|
||||
ResponseFunctionToolCallOutputItem(
|
||||
id=f"{item_id}_result_{call_id}",
|
||||
call_id=call_id,
|
||||
output=output,
|
||||
type="function_call_output",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Create ConversationItems based on what we found
|
||||
# If message has text/images/files, create a Message item
|
||||
if message_contents:
|
||||
message = OpenAIMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role=role,
|
||||
content=message_contents, # type: ignore
|
||||
status="completed",
|
||||
)
|
||||
items.append(message)
|
||||
|
||||
# Add function call items
|
||||
items.extend(function_calls)
|
||||
|
||||
# Add function result items
|
||||
items.extend(function_results)
|
||||
|
||||
# Include checkpoints from checkpoint storage as conversation items
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if checkpoint_storage:
|
||||
# Get all checkpoints for this conversation
|
||||
checkpoints = self._list_all_checkpoints(checkpoint_storage)
|
||||
for checkpoint in checkpoints:
|
||||
# Create a conversation item for each checkpoint with summary metadata
|
||||
# Full checkpoint state is NOT included here (too large for list view)
|
||||
# Use get_item() to retrieve full checkpoint details
|
||||
# Calculate approximate size of checkpoint
|
||||
import json
|
||||
|
||||
checkpoint_json = json.dumps(checkpoint.to_dict())
|
||||
checkpoint_size = len(checkpoint_json.encode("utf-8"))
|
||||
|
||||
checkpoint_item = {
|
||||
"id": f"checkpoint_{checkpoint.checkpoint_id}",
|
||||
"type": "checkpoint",
|
||||
"checkpoint_id": checkpoint.checkpoint_id,
|
||||
# Keep workflow_id for backward compatibility with existing UI payloads.
|
||||
"workflow_id": checkpoint.workflow_name,
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
"timestamp": checkpoint.timestamp,
|
||||
"status": "completed",
|
||||
"metadata": {
|
||||
# Summary metrics for list view
|
||||
"iteration_count": checkpoint.iteration_count,
|
||||
"pending_hil_count": len(checkpoint.pending_request_info_events),
|
||||
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
|
||||
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
|
||||
"size_bytes": checkpoint_size,
|
||||
"version": checkpoint.version,
|
||||
"graph_signature_hash": checkpoint.graph_signature_hash,
|
||||
},
|
||||
}
|
||||
items.append(cast(ConversationItem, checkpoint_item))
|
||||
|
||||
# Apply pagination
|
||||
if order == "desc":
|
||||
items = items[::-1]
|
||||
|
||||
start_idx = 0
|
||||
if after:
|
||||
# Find the index after the cursor
|
||||
for i, item in enumerate(items):
|
||||
if item.id == after:
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
paginated_items = items[start_idx : start_idx + limit]
|
||||
has_more = len(items) > start_idx + limit
|
||||
|
||||
return paginated_items, has_more
|
||||
|
||||
async def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get a specific conversation item by ID.
|
||||
|
||||
Supports checkpoint items - will load full checkpoint state from storage.
|
||||
For checkpoints, the full state is included in metadata.full_checkpoint.
|
||||
"""
|
||||
# First check item index for messages, function calls, etc. (O(1) lookup)
|
||||
conv_items = self._item_index.get(conversation_id, {})
|
||||
item = conv_items.get(item_id)
|
||||
if item:
|
||||
return item
|
||||
|
||||
# If not found and ID is a checkpoint, load from checkpoint storage
|
||||
if item_id.startswith("checkpoint_"):
|
||||
checkpoint_id = item_id[len("checkpoint_") :] # Remove "checkpoint_" prefix
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
return None
|
||||
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if not checkpoint_storage:
|
||||
return None
|
||||
|
||||
# Load full checkpoint from storage
|
||||
try:
|
||||
checkpoint = await checkpoint_storage.load(checkpoint_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Calculate size of checkpoint
|
||||
import json
|
||||
|
||||
checkpoint_json = json.dumps(checkpoint.to_dict())
|
||||
checkpoint_size = len(checkpoint_json.encode("utf-8"))
|
||||
|
||||
# Build checkpoint item with FULL state in metadata
|
||||
checkpoint_item = {
|
||||
"id": item_id,
|
||||
"type": "checkpoint",
|
||||
"checkpoint_id": checkpoint.checkpoint_id,
|
||||
# Keep workflow_id for backward compatibility with existing UI payloads.
|
||||
"workflow_id": checkpoint.workflow_name,
|
||||
"workflow_name": checkpoint.workflow_name,
|
||||
"timestamp": checkpoint.timestamp,
|
||||
"status": "completed",
|
||||
"metadata": {
|
||||
# Summary metrics (same as list view)
|
||||
"iteration_count": checkpoint.iteration_count,
|
||||
"pending_hil_count": len(checkpoint.pending_request_info_events),
|
||||
"has_pending_hil": len(checkpoint.pending_request_info_events) > 0,
|
||||
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
|
||||
"size_bytes": checkpoint_size,
|
||||
"version": checkpoint.version,
|
||||
"graph_signature_hash": checkpoint.graph_signature_hash,
|
||||
# 🔥 FULL checkpoint state (lazy loaded)
|
||||
"full_checkpoint": checkpoint.to_dict(),
|
||||
},
|
||||
}
|
||||
|
||||
return cast(ConversationItem, checkpoint_item)
|
||||
|
||||
return None
|
||||
|
||||
def get_session(self, conversation_id: str) -> AgentSession | None:
|
||||
"""Get AgentSession for execution - CRITICAL for agent.run()."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
return conv_data["session"] if conv_data else None
|
||||
|
||||
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
|
||||
"""Add a trace event to the conversation for context inspection.
|
||||
|
||||
Traces capture execution metadata like token usage, timing, and LLM context
|
||||
that is useful for debugging.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
trace_event: Trace event data (from ResponseTraceEvent.data)
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if conv_data:
|
||||
traces = conv_data.get("traces", [])
|
||||
traces.append(trace_event)
|
||||
conv_data["traces"] = traces
|
||||
|
||||
def get_traces(self, conversation_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all trace events for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
List of trace event dicts, or empty list if not found
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
return conv_data.get("traces", []) if conv_data else []
|
||||
|
||||
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id)."""
|
||||
results: list[Conversation] = []
|
||||
for conv_data in self._conversations.values():
|
||||
conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original
|
||||
|
||||
# Check if all filter items match
|
||||
if all(conv_meta.get(k) == v for k, v in metadata_filter.items()):
|
||||
# Enrich workflow sessions with checkpoint summary
|
||||
if conv_meta.get("type") == "workflow_session":
|
||||
checkpoint_storage = conv_data.get("checkpoint_storage")
|
||||
if checkpoint_storage:
|
||||
checkpoints = self._list_all_checkpoints(checkpoint_storage)
|
||||
latest = max(checkpoints, key=lambda cp: cp.timestamp) if checkpoints else None
|
||||
conv_meta["checkpoint_summary"] = {
|
||||
"count": len(checkpoints),
|
||||
"latest_iteration": latest.iteration_count if latest else 0,
|
||||
"has_pending_hil": len(latest.pending_request_info_events) > 0 if latest else False,
|
||||
"pending_hil_count": len(latest.pending_request_info_events) if latest else 0,
|
||||
}
|
||||
|
||||
results.append(
|
||||
Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_meta,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by created_at descending (most recent first)
|
||||
results.sort(key=lambda c: c.created_at, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _list_all_checkpoints(checkpoint_storage: Any) -> list[WorkflowCheckpoint]:
|
||||
"""Return all checkpoints from a conversation-scoped storage instance.
|
||||
|
||||
DevUI uses one checkpoint storage per conversation. Core storage APIs now
|
||||
require workflow_name filters, so we gather directly from in-memory storage
|
||||
internals to provide conversation-wide listing for UI views.
|
||||
"""
|
||||
checkpoint_map = getattr(checkpoint_storage, "_checkpoints", None)
|
||||
if isinstance(checkpoint_map, dict):
|
||||
return list(cast(dict[str, WorkflowCheckpoint], checkpoint_map).values())
|
||||
return []
|
||||
|
||||
|
||||
class CheckpointConversationManager:
|
||||
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
|
||||
|
||||
Simplified architecture: Each conversation has its own InMemoryCheckpointStorage
|
||||
stored in conv_data["checkpoint_storage"]. This manager just retrieves it.
|
||||
Session isolation comes from each conversation having a separate storage instance.
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
# Runtime validation since we need specific implementation details
|
||||
if not isinstance(conversation_store, InMemoryConversationStore):
|
||||
raise TypeError("CheckpointConversationManager currently requires InMemoryConversationStore")
|
||||
self._store: InMemoryConversationStore = conversation_store
|
||||
# Keep public reference for backward compatibility with tests
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
def get_checkpoint_storage(self, conversation_id: str) -> InMemoryCheckpointStorage:
|
||||
"""Get the checkpoint storage for a specific conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
InMemoryCheckpointStorage instance for this conversation
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
# Access internal conversations dict (we know it's InMemoryConversationStore)
|
||||
conversations_dict = cast(dict[str, dict[str, Any]], getattr(self._store, "_conversations", {}))
|
||||
conv_data = conversations_dict.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
checkpoint_storage = conv_data["checkpoint_storage"]
|
||||
if not isinstance(checkpoint_storage, InMemoryCheckpointStorage):
|
||||
raise TypeError(f"Expected InMemoryCheckpointStorage but got {type(checkpoint_storage)}")
|
||||
return checkpoint_storage
|
||||
@@ -0,0 +1,604 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Container Apps deployment manager for DevUI entities."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeploymentManager:
|
||||
"""Manages entity deployments to Azure Container Apps."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize deployment manager."""
|
||||
self._deployments: dict[str, Deployment] = {}
|
||||
|
||||
async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGenerator[DeploymentEvent, None]:
|
||||
"""Deploy entity to Azure Container Apps with streaming events.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
|
||||
Yields:
|
||||
DeploymentEvent objects for real-time progress updates
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met or deployment fails
|
||||
"""
|
||||
deployment_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
# Step 1: Validate prerequisites
|
||||
yield DeploymentEvent(
|
||||
type="deploy.validating",
|
||||
message="Checking prerequisites (Azure CLI, Docker, authentication)...",
|
||||
)
|
||||
|
||||
await self._validate_prerequisites()
|
||||
|
||||
# Step 2: Generate Dockerfile
|
||||
yield DeploymentEvent(
|
||||
type="deploy.dockerfile",
|
||||
message="Generating Dockerfile with authentication enabled...",
|
||||
)
|
||||
|
||||
_ = await self._generate_dockerfile(entity_path, config)
|
||||
|
||||
# Step 3: Generate auth token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.token",
|
||||
message="Generating secure authentication token...",
|
||||
)
|
||||
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Step 4: Discover existing Container App Environment
|
||||
yield DeploymentEvent(
|
||||
type="deploy.environment",
|
||||
message="Checking for existing Container App Environment...",
|
||||
)
|
||||
|
||||
# Step 5: Build and deploy with Azure CLI
|
||||
yield DeploymentEvent(
|
||||
type="deploy.building",
|
||||
message=f"Deploying to Azure Container Apps ({config.region})...",
|
||||
)
|
||||
|
||||
# Create a queue for streaming events from subprocess
|
||||
event_queue: asyncio.Queue[DeploymentEvent] = asyncio.Queue()
|
||||
|
||||
# Run deployment in background task with event queue
|
||||
deployment_task = asyncio.create_task(self._deploy_to_azure(config, entity_path, auth_token, event_queue))
|
||||
|
||||
# Stream events from queue while deployment runs
|
||||
while True:
|
||||
try:
|
||||
# Check if deployment task is done
|
||||
if deployment_task.done():
|
||||
# Get the result or exception
|
||||
deployment_url = await deployment_task
|
||||
break
|
||||
|
||||
# Get event from queue with short timeout
|
||||
yield await asyncio.wait_for(event_queue.get(), timeout=0.1)
|
||||
except asyncio.TimeoutError:
|
||||
# No event in queue, continue waiting
|
||||
continue
|
||||
|
||||
# Step 5: Store deployment record
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url=deployment_url,
|
||||
status="deployed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
# Step 6: Success - return URL and token
|
||||
yield DeploymentEvent(
|
||||
type="deploy.completed",
|
||||
message=f"Deployment successful! URL: {deployment_url}",
|
||||
url=deployment_url,
|
||||
auth_token=auth_token, # Shown once to user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Deployment failed: {e!s}"
|
||||
logger.exception(error_msg)
|
||||
|
||||
# Store failed deployment
|
||||
deployment = Deployment(
|
||||
id=deployment_id,
|
||||
entity_id=config.entity_id,
|
||||
resource_group=config.resource_group,
|
||||
app_name=config.app_name,
|
||||
region=config.region,
|
||||
url="",
|
||||
status="failed",
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
error=str(e),
|
||||
)
|
||||
self._deployments[deployment_id] = deployment
|
||||
|
||||
yield DeploymentEvent(
|
||||
type="deploy.failed",
|
||||
message=error_msg,
|
||||
)
|
||||
|
||||
async def _validate_prerequisites(self) -> None:
|
||||
"""Validate that Azure CLI, Docker, authentication, and resource providers are available.
|
||||
|
||||
Raises:
|
||||
ValueError: If prerequisites not met
|
||||
"""
|
||||
# Check Azure CLI
|
||||
az_check = await asyncio.create_subprocess_exec(
|
||||
"az", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await az_check.communicate()
|
||||
if az_check.returncode != 0:
|
||||
raise ValueError(
|
||||
"Azure CLI not found. Install from: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
||||
)
|
||||
|
||||
# Check Docker
|
||||
docker_check = await asyncio.create_subprocess_exec(
|
||||
"docker", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await docker_check.communicate()
|
||||
if docker_check.returncode != 0:
|
||||
raise ValueError("Docker not found. Install from: https://www.docker.com/get-started")
|
||||
|
||||
# Check Azure authentication
|
||||
az_account_check = await asyncio.create_subprocess_exec(
|
||||
"az", "account", "show", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await az_account_check.communicate()
|
||||
if az_account_check.returncode != 0:
|
||||
raise ValueError("Not authenticated with Azure. Run: az login")
|
||||
|
||||
# Check required resource providers are registered
|
||||
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
|
||||
unregistered_providers: list[str] = []
|
||||
|
||||
# Get list of registered providers
|
||||
provider_check = await asyncio.create_subprocess_exec(
|
||||
"az",
|
||||
"provider",
|
||||
"list",
|
||||
"--query",
|
||||
"[?registrationState=='Registered'].namespace",
|
||||
"--output",
|
||||
"json",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _stderr = await provider_check.communicate()
|
||||
|
||||
if provider_check.returncode == 0:
|
||||
import json
|
||||
|
||||
try:
|
||||
registered_raw = json.loads(stdout.decode())
|
||||
registered: list[str] = []
|
||||
if isinstance(registered_raw, list):
|
||||
for item_obj in cast(list[object], registered_raw):
|
||||
if isinstance(item_obj, str):
|
||||
registered.append(item_obj)
|
||||
for provider in required_providers:
|
||||
if provider not in registered:
|
||||
unregistered_providers.append(provider)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Could not parse provider list, skipping provider validation")
|
||||
else:
|
||||
logger.warning("Could not check provider registration status")
|
||||
|
||||
if unregistered_providers:
|
||||
commands = [f"az provider register -n {p} --wait" for p in unregistered_providers]
|
||||
raise ValueError(
|
||||
f"Required Azure resource providers not registered: {', '.join(unregistered_providers)}\n\n"
|
||||
f"Register them by running:\n" + "\n".join(commands) + "\n\n"
|
||||
"This is a one-time setup per Azure subscription."
|
||||
)
|
||||
|
||||
logger.info("All prerequisites validated successfully")
|
||||
|
||||
async def _generate_dockerfile(self, entity_path: Path, config: DeploymentConfig) -> Path:
|
||||
"""Generate Dockerfile for entity deployment.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory
|
||||
config: Deployment configuration
|
||||
|
||||
Returns:
|
||||
Path to generated Dockerfile
|
||||
"""
|
||||
# Validate ui_mode
|
||||
if config.ui_mode not in ["user", "developer"]:
|
||||
raise ValueError(f"Invalid ui_mode: {config.ui_mode}. Must be 'user' or 'developer'.")
|
||||
|
||||
# Check if requirements.txt exists in the entity directory
|
||||
has_requirements = (entity_path / "requirements.txt").exists()
|
||||
|
||||
requirements_section = ""
|
||||
if has_requirements:
|
||||
logger.info(f"Found requirements.txt in {entity_path}, will include in Dockerfile")
|
||||
requirements_section = """# Install entity dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
"""
|
||||
else:
|
||||
logger.info(f"No requirements.txt found in {entity_path}, skipping dependency installation")
|
||||
|
||||
dockerfile_content = f"""FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
{requirements_section}# Install DevUI from PyPI
|
||||
RUN pip install agent-framework-devui --pre
|
||||
|
||||
# Copy entity code
|
||||
COPY . /app/entity/
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Launch DevUI. Auth is enabled by default and reads the token from the environment.
|
||||
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080"]
|
||||
"""
|
||||
|
||||
dockerfile_path = entity_path / "Dockerfile"
|
||||
|
||||
# Warn if Dockerfile already exists
|
||||
if dockerfile_path.exists():
|
||||
logger.warning(f"Dockerfile already exists at {dockerfile_path}, overwriting...")
|
||||
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
logger.info(f"Generated Dockerfile at {dockerfile_path}")
|
||||
|
||||
return dockerfile_path
|
||||
|
||||
async def _discover_container_app_environment(self, resource_group: str, region: str) -> str | None:
|
||||
"""Discover existing Container App Environment in resource group.
|
||||
|
||||
Args:
|
||||
resource_group: Resource group name
|
||||
region: Azure region (for filtering if needed)
|
||||
|
||||
Returns:
|
||||
Environment name if found, None otherwise
|
||||
"""
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"env",
|
||||
"list",
|
||||
"--resource-group",
|
||||
resource_group,
|
||||
"--query",
|
||||
"[0].name",
|
||||
"--output",
|
||||
"tsv",
|
||||
]
|
||||
|
||||
logger.info(f"Discovering existing Container App Environments in {resource_group}...")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
env_name = stdout.decode().strip()
|
||||
if env_name:
|
||||
logger.info(f"Found existing environment: {env_name}")
|
||||
return env_name
|
||||
logger.info("No existing environments found in resource group")
|
||||
return None
|
||||
logger.warning(f"Failed to query environments: {stderr.decode()}")
|
||||
return None
|
||||
|
||||
async def _deploy_to_azure(
|
||||
self, config: DeploymentConfig, entity_path: Path, auth_token: str, event_queue: asyncio.Queue[DeploymentEvent]
|
||||
) -> str:
|
||||
"""Deploy to Azure Container Apps, reusing existing environments.
|
||||
|
||||
Args:
|
||||
config: Deployment configuration
|
||||
entity_path: Path to entity directory
|
||||
auth_token: Authentication token to inject
|
||||
event_queue: Queue for streaming progress events
|
||||
|
||||
Returns:
|
||||
Deployment URL
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment fails
|
||||
"""
|
||||
# Step 1: Try to discover existing Container App Environment
|
||||
existing_env = await self._discover_container_app_environment(config.resource_group, config.region)
|
||||
|
||||
if existing_env:
|
||||
# Use existing environment - avoids needing environment creation permissions
|
||||
logger.info(f"Reusing existing Container App Environment: {existing_env} (cost efficient, no side effects)")
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--environment",
|
||||
existing_env,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
logger.info(f"Creating new Container App '{config.app_name}' in environment '{existing_env}'...")
|
||||
else:
|
||||
# No existing environment - try to create one (may fail if no permissions)
|
||||
logger.warning(
|
||||
"No existing Container App Environment found. "
|
||||
"Attempting to create new environment (requires Microsoft.App/managedEnvironments/write permission)..."
|
||||
)
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"up",
|
||||
"--name",
|
||||
config.app_name,
|
||||
"--resource-group",
|
||||
config.resource_group,
|
||||
"--location",
|
||||
config.region,
|
||||
"--source",
|
||||
str(entity_path),
|
||||
"--env-vars",
|
||||
f"DEVUI_AUTH_TOKEN={auth_token}",
|
||||
"--ingress",
|
||||
"external",
|
||||
"--target-port",
|
||||
"8080",
|
||||
]
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Stream output line by line
|
||||
output_lines: list[str] = []
|
||||
try:
|
||||
if not process.stdout:
|
||||
raise ValueError("Failed to capture process output")
|
||||
|
||||
while True:
|
||||
# Read with timeout
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=600)
|
||||
if not line:
|
||||
break
|
||||
|
||||
line_text = line.decode().strip()
|
||||
if line_text:
|
||||
output_lines.append(line_text)
|
||||
|
||||
# Stream meaningful updates to user
|
||||
if "WARNING:" in line_text:
|
||||
# Parse and send user-friendly warnings
|
||||
if "Creating resource group" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message=f"Creating resource group '{config.resource_group}'...",
|
||||
)
|
||||
)
|
||||
elif "Creating ContainerAppEnvironment" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Setting up Container App Environment (this may take 2-3 minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Registering resource provider" in line_text:
|
||||
provider = line_text.split("provider")[-1].strip()
|
||||
if provider.endswith("..."):
|
||||
provider = provider[:-3]
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message=f"Registering Azure provider{provider}..."
|
||||
)
|
||||
)
|
||||
elif "Creating Azure Container Registry" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Container Registry for your images..."
|
||||
)
|
||||
)
|
||||
elif "No Log Analytics workspace" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Creating Log Analytics workspace for monitoring..."
|
||||
)
|
||||
)
|
||||
elif "Building image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress",
|
||||
message="Building Docker image (this may take several minutes)...",
|
||||
)
|
||||
)
|
||||
elif "Pushing image" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(
|
||||
type="deploy.progress", message="Pushing image to Azure Container Registry..."
|
||||
)
|
||||
)
|
||||
elif "Creating Container App" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Creating your Container App...")
|
||||
)
|
||||
elif "Container app created" in line_text:
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Container app created successfully!")
|
||||
)
|
||||
elif "ERROR:" in line_text:
|
||||
# Stream errors immediately
|
||||
await event_queue.put(DeploymentEvent(type="deploy.error", message=line_text))
|
||||
elif "Step" in line_text and "/" in line_text:
|
||||
# Docker build steps
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message=f"Docker build: {line_text}")
|
||||
)
|
||||
elif "https://" in line_text:
|
||||
# Try to extract all URLs and check if any is on azurecontainerapps.io
|
||||
urls = re.findall(r'https://[^\s<>"]+', line_text)
|
||||
for url in urls:
|
||||
# Strip common trailing punctuation to ensure clean URL parsing
|
||||
url_clean = url.rstrip(".,;:!?'\")}]")
|
||||
parsed_url = urlparse(str(url_clean))
|
||||
host = parsed_url.hostname
|
||||
if isinstance(host, str) and (
|
||||
host == "azurecontainerapps.io" or host.endswith(".azurecontainerapps.io")
|
||||
):
|
||||
await event_queue.put(
|
||||
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
|
||||
)
|
||||
break
|
||||
|
||||
# Wait for process to complete
|
||||
return_code = await process.wait()
|
||||
|
||||
if return_code != 0:
|
||||
error_output = "\n".join(output_lines[-10:]) # Last 10 lines for context
|
||||
raise ValueError(f"Azure deployment failed:\n{error_output}")
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
process.kill()
|
||||
raise ValueError(
|
||||
"Azure deployment timed out after 10 minutes. Please check Azure portal for status."
|
||||
) from e
|
||||
|
||||
# Parse output to extract FQDN
|
||||
output = "\n".join(output_lines)
|
||||
logger.debug(f"Azure CLI output: {output}")
|
||||
|
||||
# Extract FQDN from output (az containerapp up returns it)
|
||||
# Format: https://<app-name>.<random-id>.<region>.azurecontainerapps.io
|
||||
deployment_url = self._extract_fqdn_from_output(output, config.app_name)
|
||||
|
||||
logger.info(f"Deployment successful: {deployment_url}")
|
||||
return deployment_url
|
||||
|
||||
def _extract_fqdn_from_output(self, output: str, app_name: str) -> str:
|
||||
"""Extract FQDN from Azure CLI output.
|
||||
|
||||
Args:
|
||||
output: Azure CLI command output
|
||||
app_name: Container app name
|
||||
|
||||
Returns:
|
||||
Full HTTPS URL to deployed app
|
||||
"""
|
||||
# Try to find FQDN in output
|
||||
for line in output.split("\n"):
|
||||
if "fqdn" in line.lower() or app_name in line:
|
||||
# Extract URL-like string
|
||||
match = re.search(r"https?://[\w\-\.]+\.azurecontainerapps\.io", line)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# If we can't extract FQDN, fail explicitly rather than return a broken URL
|
||||
logger.error(f"Could not extract FQDN from Azure CLI output. Output:\n{output}")
|
||||
raise ValueError(
|
||||
"Could not extract deployment URL from Azure CLI output. "
|
||||
"The deployment may have succeeded - check the Azure portal for your container app URL."
|
||||
)
|
||||
|
||||
async def list_deployments(self, entity_id: str | None = None) -> list[Deployment]:
|
||||
"""List all deployments, optionally filtered by entity.
|
||||
|
||||
Args:
|
||||
entity_id: Optional entity ID to filter by
|
||||
|
||||
Returns:
|
||||
List of deployment records
|
||||
"""
|
||||
if entity_id:
|
||||
return [d for d in self._deployments.values() if d.entity_id == entity_id]
|
||||
return list(self._deployments.values())
|
||||
|
||||
async def get_deployment(self, deployment_id: str) -> Deployment | None:
|
||||
"""Get deployment by ID.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID
|
||||
|
||||
Returns:
|
||||
Deployment record or None if not found
|
||||
"""
|
||||
return self._deployments.get(deployment_id)
|
||||
|
||||
async def delete_deployment(self, deployment_id: str) -> None:
|
||||
"""Delete deployment from Azure Container Apps.
|
||||
|
||||
Args:
|
||||
deployment_id: Deployment ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment not found or deletion fails
|
||||
"""
|
||||
deployment = self._deployments.get(deployment_id)
|
||||
if not deployment:
|
||||
raise ValueError(f"Deployment {deployment_id} not found")
|
||||
|
||||
# Execute: az containerapp delete
|
||||
cmd = [
|
||||
"az",
|
||||
"containerapp",
|
||||
"delete",
|
||||
"--name",
|
||||
deployment.app_name,
|
||||
"--resource-group",
|
||||
deployment.resource_group,
|
||||
"--yes", # Skip confirmation
|
||||
]
|
||||
|
||||
logger.info(f"Deleting deployment: {' '.join(cmd)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_output = stderr.decode() if stderr else stdout.decode()
|
||||
raise ValueError(f"Deployment deletion failed: {error_output}")
|
||||
|
||||
# Remove from store
|
||||
del self._deployments[deployment_id]
|
||||
logger.info(f"Deployment {deployment_id} deleted successfully")
|
||||
@@ -0,0 +1,968 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework entity discovery implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .models._discovery_models import EntityInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityDiscovery:
|
||||
"""Discovery for Agent Framework entities - agents and workflows."""
|
||||
|
||||
def __init__(self, entities_dir: str | None = None):
|
||||
"""Initialize entity discovery.
|
||||
|
||||
Args:
|
||||
entities_dir: Directory to scan for entities (optional)
|
||||
"""
|
||||
self.entities_dir = entities_dir
|
||||
self._entities: dict[str, EntityInfo] = {}
|
||||
self._loaded_objects: dict[str, Any] = {}
|
||||
self._cleanup_hooks: dict[str, list[Any]] = {}
|
||||
|
||||
async def discover_entities(self) -> list[EntityInfo]:
|
||||
"""Scan for Agent Framework entities.
|
||||
|
||||
Returns:
|
||||
List of discovered entities
|
||||
"""
|
||||
if not self.entities_dir:
|
||||
logger.info("No Agent Framework entities directory configured")
|
||||
return []
|
||||
|
||||
entities_dir = Path(self.entities_dir).resolve() # noqa: ASYNC240
|
||||
await self._scan_entities_directory(entities_dir)
|
||||
|
||||
logger.info(f"Discovered {len(self._entities)} Agent Framework entities")
|
||||
return self.list_entities()
|
||||
|
||||
def get_entity_info(self, entity_id: str) -> EntityInfo | None:
|
||||
"""Get entity metadata.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
Entity information or None if not found
|
||||
"""
|
||||
return self._entities.get(entity_id)
|
||||
|
||||
def get_entity_object(self, entity_id: str) -> Any | None:
|
||||
"""Get the actual loaded entity object.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
Entity object or None if not found
|
||||
"""
|
||||
return self._loaded_objects.get(entity_id)
|
||||
|
||||
async def load_entity(self, entity_id: str, checkpoint_manager: Any = None) -> Any:
|
||||
"""Load entity on-demand and inject checkpoint storage for workflows.
|
||||
|
||||
This method implements lazy loading by importing the entity module only when needed.
|
||||
In-memory entities are returned from cache immediately.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
checkpoint_manager: Optional checkpoint manager for workflow storage injection
|
||||
|
||||
Returns:
|
||||
Loaded entity object
|
||||
|
||||
Raises:
|
||||
ValueError: If entity not found or cannot be loaded
|
||||
"""
|
||||
# Check if already loaded (includes in-memory entities)
|
||||
if entity_id in self._loaded_objects:
|
||||
logger.debug(f"Entity {entity_id} already loaded (cache hit)")
|
||||
return self._loaded_objects[entity_id]
|
||||
|
||||
# Get entity metadata
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if not entity_info:
|
||||
raise ValueError(f"Entity {entity_id} not found in registry")
|
||||
|
||||
# In-memory entities should never reach here (they're pre-loaded)
|
||||
if entity_info.source == "in_memory":
|
||||
raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache")
|
||||
|
||||
logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})")
|
||||
|
||||
# Load based on source - only directory and in-memory are supported
|
||||
if entity_info.source == "directory":
|
||||
entity_obj = await self._load_directory_entity(entity_id, entity_info)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported entity source: {entity_info.source}. "
|
||||
f"Only 'directory' and 'in-memory' sources are supported."
|
||||
)
|
||||
|
||||
# Note: Checkpoint storage is now injected at runtime via run() parameter,
|
||||
# not at load time. This provides cleaner architecture and explicit control flow.
|
||||
# See _executor.py _execute_workflow() for runtime checkpoint storage injection.
|
||||
|
||||
# Enrich metadata with actual entity data
|
||||
# Don't pass entity_type if it's "unknown" - let inference determine the real type
|
||||
enriched_info = await self.create_entity_info_from_object(
|
||||
entity_obj,
|
||||
entity_type=entity_info.type if entity_info.type != "unknown" else None,
|
||||
source=entity_info.source,
|
||||
)
|
||||
# IMPORTANT: Preserve the original entity_id (enrichment generates a new one)
|
||||
enriched_info.id = entity_id
|
||||
# Preserve the original path from sparse metadata
|
||||
if "path" in entity_info.metadata:
|
||||
enriched_info.metadata["path"] = entity_info.metadata["path"]
|
||||
# Now that we have the path, properly check deployment support
|
||||
entity_path = Path(entity_info.metadata["path"])
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(entity_path, entity_info.source)
|
||||
enriched_info.deployment_supported = deployment_supported
|
||||
enriched_info.deployment_reason = deployment_reason
|
||||
enriched_info.metadata["lazy_loaded"] = True
|
||||
self._entities[entity_id] = enriched_info
|
||||
|
||||
# Cache the loaded object
|
||||
self._loaded_objects[entity_id] = entity_obj
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.info(f"Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
|
||||
|
||||
return entity_obj
|
||||
|
||||
async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any:
|
||||
"""Load entity from directory (imports module).
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
entity_info: Entity metadata
|
||||
|
||||
Returns:
|
||||
Loaded entity object
|
||||
"""
|
||||
# Get directory path from metadata
|
||||
dir_path = Path(entity_info.metadata.get("path", ""))
|
||||
if not dir_path.exists(): # noqa: ASYNC240
|
||||
raise ValueError(f"Entity directory not found: {dir_path}")
|
||||
|
||||
# Load .env if it exists
|
||||
if dir_path.is_dir(): # noqa: ASYNC240
|
||||
self._load_env_for_entity(dir_path)
|
||||
else:
|
||||
self._load_env_for_entity(dir_path.parent)
|
||||
|
||||
# Import the module
|
||||
if dir_path.is_dir(): # noqa: ASYNC240
|
||||
# Directory-based entity - try different import patterns
|
||||
import_patterns = [
|
||||
entity_id,
|
||||
f"{entity_id}.agent",
|
||||
f"{entity_id}.workflow",
|
||||
]
|
||||
|
||||
# Track import errors to provide meaningful feedback
|
||||
import_errors: list[tuple[str, Exception]] = []
|
||||
|
||||
for pattern in import_patterns:
|
||||
module, error = self._load_module_from_pattern(pattern)
|
||||
if error:
|
||||
import_errors.append((pattern, error))
|
||||
if module:
|
||||
# Find entity in module - pass entity_id so registration uses correct ID
|
||||
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
|
||||
if entity_obj:
|
||||
return entity_obj
|
||||
|
||||
# If we have import errors, raise the most informative one
|
||||
if import_errors:
|
||||
# Prefer errors from the main module pattern (entity_id) or agent submodule
|
||||
for pattern, error in import_errors:
|
||||
if pattern == entity_id or pattern.endswith(".agent"):
|
||||
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
|
||||
# Fall back to first error
|
||||
pattern, error = import_errors[0]
|
||||
raise ValueError(f"Failed to load entity '{entity_id}': {error}") from error
|
||||
|
||||
raise ValueError(f"No valid entity found in {dir_path}")
|
||||
# File-based entity
|
||||
module = self._load_module_from_file(dir_path, entity_id)
|
||||
if module:
|
||||
entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
|
||||
if entity_obj:
|
||||
return entity_obj
|
||||
|
||||
raise ValueError(f"No valid entity found in {dir_path}")
|
||||
|
||||
def list_entities(self) -> list[EntityInfo]:
|
||||
"""List all discovered entities.
|
||||
|
||||
Returns:
|
||||
List of all entity information
|
||||
"""
|
||||
return list(self._entities.values())
|
||||
|
||||
def get_cleanup_hooks(self, entity_id: str) -> list[Any]:
|
||||
"""Get cleanup hooks registered for an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier
|
||||
|
||||
Returns:
|
||||
List of cleanup hooks for the entity
|
||||
"""
|
||||
return self._cleanup_hooks.get(entity_id, [])
|
||||
|
||||
def invalidate_entity(self, entity_id: str) -> None:
|
||||
"""Invalidate (clear cache for) an entity to enable hot reload.
|
||||
|
||||
This removes the entity from the loaded objects cache and clears its module
|
||||
from Python's sys.modules cache. The entity metadata remains, so it will be
|
||||
reimported on next access.
|
||||
|
||||
Args:
|
||||
entity_id: Entity identifier to invalidate
|
||||
"""
|
||||
# Check if entity is in-memory - these cannot be invalidated
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if entity_info and entity_info.source == "in_memory":
|
||||
logger.warning(
|
||||
f"Attempted to invalidate in-memory entity {entity_id} - ignoring "
|
||||
f"(in-memory entities cannot be reloaded)"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove from loaded objects cache
|
||||
if entity_id in self._loaded_objects:
|
||||
del self._loaded_objects[entity_id]
|
||||
logger.info(f"Cleared loaded object cache for: {entity_id}")
|
||||
|
||||
# Clear from Python's module cache (including submodules)
|
||||
keys_to_delete = [
|
||||
module_name
|
||||
for module_name in sys.modules
|
||||
if module_name == entity_id or module_name.startswith(f"{entity_id}.")
|
||||
]
|
||||
for key in keys_to_delete:
|
||||
del sys.modules[key]
|
||||
logger.debug(f"Cleared module cache: {key}")
|
||||
|
||||
# Reset lazy_loaded flag in metadata
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if entity_info and "lazy_loaded" in entity_info.metadata:
|
||||
entity_info.metadata["lazy_loaded"] = False
|
||||
|
||||
logger.info(f"Entity invalidated: {entity_id} (will reload on next access)")
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Invalidate all cached entities.
|
||||
|
||||
Useful for forcing a complete reload of all entities.
|
||||
"""
|
||||
entity_ids = list(self._loaded_objects.keys())
|
||||
for entity_id in entity_ids:
|
||||
self.invalidate_entity(entity_id)
|
||||
logger.info(f"Invalidated {len(entity_ids)} entities")
|
||||
|
||||
def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
|
||||
"""Register an entity with both metadata and object.
|
||||
|
||||
Args:
|
||||
entity_id: Unique entity identifier
|
||||
entity_info: Entity metadata
|
||||
entity_object: Actual entity object for execution
|
||||
"""
|
||||
self._entities[entity_id] = entity_info
|
||||
self._loaded_objects[entity_id] = entity_object
|
||||
|
||||
# Check module-level registry for cleanup hooks
|
||||
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
|
||||
|
||||
registered_hooks = _get_registered_cleanup_hooks(entity_object)
|
||||
if registered_hooks:
|
||||
if entity_id not in self._cleanup_hooks:
|
||||
self._cleanup_hooks[entity_id] = []
|
||||
self._cleanup_hooks[entity_id].extend(registered_hooks)
|
||||
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
|
||||
|
||||
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
|
||||
|
||||
async def create_entity_info_from_object(
|
||||
self, entity_object: Any, entity_type: str | None = None, source: str = "in_memory"
|
||||
) -> EntityInfo:
|
||||
"""Create EntityInfo from Agent Framework entity object.
|
||||
|
||||
Args:
|
||||
entity_object: Agent Framework entity object
|
||||
entity_type: Optional entity type override
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
|
||||
Returns:
|
||||
EntityInfo with Agent Framework specific metadata
|
||||
"""
|
||||
# Determine entity type if not provided
|
||||
if entity_type is None:
|
||||
entity_type = "agent"
|
||||
# Check if it's a workflow
|
||||
if hasattr(entity_object, "get_executors_list") or hasattr(entity_object, "executors"):
|
||||
entity_type = "workflow"
|
||||
|
||||
# Extract metadata with improved fallback naming
|
||||
name = getattr(entity_object, "name", None)
|
||||
if not name:
|
||||
# In-memory entities: use class name as it's more readable than UUID
|
||||
class_name = entity_object.__class__.__name__
|
||||
name = f"{entity_type.title()} {class_name}"
|
||||
description = getattr(entity_object, "description", "")
|
||||
|
||||
# Generate entity ID using Agent Framework specific naming
|
||||
entity_id = self._generate_entity_id(entity_object, entity_type, source)
|
||||
|
||||
# Extract tools/executors using Agent Framework specific logic
|
||||
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_provider_list = None
|
||||
middlewares_list = None
|
||||
|
||||
if entity_type == "agent":
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
agent_meta = extract_agent_metadata(entity_object)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_provider_list = agent_meta["context_provider"]
|
||||
middlewares_list = agent_meta["middleware"]
|
||||
|
||||
# Log helpful info about agent capabilities (before creating EntityInfo)
|
||||
if entity_type == "agent":
|
||||
has_run = hasattr(entity_object, "run")
|
||||
|
||||
if not has_run:
|
||||
logger.warning(f"Agent '{entity_id}' lacks run() method. May not work.")
|
||||
|
||||
# Check deployment support based on source
|
||||
# For directory-based entities, we need the path to verify deployment support
|
||||
deployment_supported = False
|
||||
deployment_reason = "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
if source == "directory":
|
||||
# Directory-based entity - will be checked properly after enrichment when path is available
|
||||
# For now, mark as potentially deployable - will be re-evaluated after enrichment
|
||||
deployment_supported = True
|
||||
deployment_reason = "Ready for deployment (pending path verification)"
|
||||
|
||||
class_name = type(entity_object).__name__
|
||||
|
||||
# Create EntityInfo with Agent Framework specifics
|
||||
return EntityInfo(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
description=description,
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
source=source, # IMPORTANT: Pass the source parameter
|
||||
tools=[str(tool) for tool in (tools_list or [])],
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_provider=context_provider_list,
|
||||
middleware=middlewares_list,
|
||||
executors=tools_list if entity_type == "workflow" else [],
|
||||
input_schema={"type": "string"}, # Default schema
|
||||
start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"source": "agent_framework_object",
|
||||
"class_name": class_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def _scan_entities_directory(self, entities_dir: Path) -> None:
|
||||
"""Scan the entities directory for Agent Framework entities (lazy loading).
|
||||
|
||||
This method scans the filesystem WITHOUT importing modules, creating sparse
|
||||
metadata that will be enriched on-demand when entities are accessed.
|
||||
|
||||
Args:
|
||||
entities_dir: Directory to scan for entities
|
||||
"""
|
||||
if not entities_dir.exists(): # noqa: ASYNC240
|
||||
logger.warning(f"Entities directory not found: {entities_dir}")
|
||||
return
|
||||
|
||||
logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...")
|
||||
|
||||
# Add entities directory to Python path if not already there
|
||||
entities_dir_str = str(entities_dir)
|
||||
if entities_dir_str not in sys.path:
|
||||
sys.path.insert(0, entities_dir_str)
|
||||
|
||||
# Scan for directories and Python files WITHOUT importing
|
||||
for item in entities_dir.iterdir(): # noqa: ASYNC240
|
||||
if item.name.startswith(".") or item.name == "__pycache__":
|
||||
continue
|
||||
|
||||
if item.is_dir() and self._looks_like_entity(item):
|
||||
# Directory-based entity - create sparse metadata
|
||||
self._register_sparse_entity(item)
|
||||
elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
|
||||
# Single file entity - create sparse metadata
|
||||
self._register_sparse_file_entity(item)
|
||||
|
||||
def _looks_like_entity(self, dir_path: Path) -> bool:
|
||||
"""Check if directory contains an entity (without importing).
|
||||
|
||||
Args:
|
||||
dir_path: Directory to check
|
||||
|
||||
Returns:
|
||||
True if directory appears to contain an entity
|
||||
"""
|
||||
return (
|
||||
(dir_path / "agent.py").exists()
|
||||
or (dir_path / "workflow.py").exists()
|
||||
or (dir_path / "__init__.py").exists()
|
||||
)
|
||||
|
||||
def _detect_entity_type(self, dir_path: Path) -> str:
|
||||
"""Detect entity type from directory structure (without importing).
|
||||
|
||||
Uses filename conventions to determine entity type:
|
||||
- workflow.py → "workflow"
|
||||
- agent.py → "agent"
|
||||
- both or neither → "unknown"
|
||||
|
||||
Args:
|
||||
dir_path: Directory to analyze
|
||||
|
||||
Returns:
|
||||
Entity type: "workflow", "agent", or "unknown"
|
||||
"""
|
||||
has_agent = (dir_path / "agent.py").exists()
|
||||
has_workflow = (dir_path / "workflow.py").exists()
|
||||
|
||||
if has_agent and has_workflow:
|
||||
# Both files exist - ambiguous, mark as unknown
|
||||
return "unknown"
|
||||
if has_workflow:
|
||||
return "workflow"
|
||||
if has_agent:
|
||||
return "agent"
|
||||
# Has __init__.py but no specific file
|
||||
return "unknown"
|
||||
|
||||
def _check_deployment_support(self, entity_path: Path, source: str) -> tuple[bool, str | None]:
|
||||
"""Check if entity can be deployed to Azure Container Apps.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory or file
|
||||
source: Entity source ("directory" or "in_memory")
|
||||
|
||||
Returns:
|
||||
Tuple of (supported, reason) explaining deployment eligibility
|
||||
"""
|
||||
# In-memory entities cannot be deployed
|
||||
if source == "in_memory":
|
||||
return False, "In-memory entities cannot be deployed (no source directory)"
|
||||
|
||||
# File-based entities need a directory structure for deployment
|
||||
if not entity_path.is_dir():
|
||||
return False, "Only directory-based entities can be deployed"
|
||||
|
||||
# Must have __init__.py
|
||||
if not (entity_path / "__init__.py").exists():
|
||||
return False, "Missing __init__.py file"
|
||||
|
||||
# Passed all checks
|
||||
return True, "Ready for deployment"
|
||||
|
||||
def _register_sparse_entity(self, dir_path: Path) -> None:
|
||||
"""Register entity with sparse metadata (no import).
|
||||
|
||||
Args:
|
||||
dir_path: Entity directory
|
||||
"""
|
||||
entity_id = dir_path.name
|
||||
entity_type = self._detect_entity_type(dir_path)
|
||||
|
||||
# Check deployment support
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(dir_path, "directory")
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
name=entity_id.replace("_", " ").title(),
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
tools=[], # Sparse - will be populated on load
|
||||
description="", # Sparse - will be populated on load
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(dir_path),
|
||||
"discovered": True,
|
||||
"lazy_loaded": False,
|
||||
},
|
||||
)
|
||||
|
||||
self._entities[entity_id] = entity_info
|
||||
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
|
||||
|
||||
def _has_entity_exports(self, file_path: Path) -> bool:
|
||||
"""Check if a Python file has entity exports (agent or workflow) using AST parsing.
|
||||
|
||||
This safely checks for module-level assignments like:
|
||||
- agent = Agent(...)
|
||||
- workflow = WorkflowBuilder(start_executor=...)...
|
||||
|
||||
Args:
|
||||
file_path: Python file to check
|
||||
|
||||
Returns:
|
||||
True if file has 'agent' or 'workflow' exports
|
||||
"""
|
||||
try:
|
||||
# Read and parse the file's AST
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(file_path))
|
||||
|
||||
# Look for module-level assignments of 'agent' or 'workflow'
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ("agent", "workflow"):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse {file_path} for entity exports: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _register_sparse_file_entity(self, file_path: Path) -> None:
|
||||
"""Register file-based entity with sparse metadata (no import).
|
||||
|
||||
Args:
|
||||
file_path: Entity Python file
|
||||
"""
|
||||
# Check if file has valid entity exports using AST parsing
|
||||
if not self._has_entity_exports(file_path):
|
||||
logger.debug(f"Skipping {file_path.name} - no 'agent' or 'workflow' exports found")
|
||||
return
|
||||
|
||||
entity_id = file_path.stem
|
||||
|
||||
# Check deployment support (file-based entities cannot be deployed)
|
||||
deployment_supported, deployment_reason = self._check_deployment_support(file_path, "directory")
|
||||
|
||||
# File-based entities are typically agents, but we can't know for sure without importing
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
name=entity_id.replace("_", " ").title(),
|
||||
type="unknown", # Will be determined on load
|
||||
framework="agent_framework",
|
||||
tools=[],
|
||||
description="",
|
||||
source="directory",
|
||||
deployment_supported=deployment_supported,
|
||||
deployment_reason=deployment_reason,
|
||||
metadata={
|
||||
"path": str(file_path),
|
||||
"discovered": True,
|
||||
"lazy_loaded": False,
|
||||
},
|
||||
)
|
||||
|
||||
self._entities[entity_id] = entity_info
|
||||
logger.debug(f"Registered sparse file entity: {entity_id}")
|
||||
|
||||
def _load_env_for_entity(self, entity_path: Path) -> bool:
|
||||
"""Load .env file for an entity.
|
||||
|
||||
Args:
|
||||
entity_path: Path to entity directory
|
||||
|
||||
Returns:
|
||||
True if .env was loaded successfully
|
||||
"""
|
||||
# Check for .env in the entity folder first
|
||||
env_file = entity_path / ".env"
|
||||
if self._load_env_file(env_file):
|
||||
return True
|
||||
|
||||
# Check one level up (the entities directory) for safety
|
||||
if self.entities_dir:
|
||||
entities_dir = Path(self.entities_dir).resolve()
|
||||
entities_env = entities_dir / ".env"
|
||||
if self._load_env_file(entities_env):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _load_env_file(self, env_path: Path) -> bool:
|
||||
"""Load environment variables from .env file.
|
||||
|
||||
Args:
|
||||
env_path: Path to .env file
|
||||
|
||||
Returns:
|
||||
True if file was loaded successfully
|
||||
"""
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=True)
|
||||
logger.debug(f"Loaded .env from {env_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_module_from_pattern(self, pattern: str) -> tuple[Any | None, Exception | None]:
|
||||
"""Load module using import pattern.
|
||||
|
||||
Args:
|
||||
pattern: Import pattern to try
|
||||
|
||||
Returns:
|
||||
Tuple of (loaded module or None, error or None)
|
||||
"""
|
||||
try:
|
||||
# Check if module exists first
|
||||
spec = importlib.util.find_spec(pattern)
|
||||
if spec is None:
|
||||
return None, None
|
||||
|
||||
module = importlib.import_module(pattern)
|
||||
logger.debug(f"Successfully imported {pattern}")
|
||||
return module, None
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
# Distinguish between "module pattern doesn't exist" vs "module has import errors"
|
||||
# If the missing module is the pattern itself, it's just not found (try next pattern)
|
||||
# If the missing module is something else (a dependency), capture the error
|
||||
missing_module = getattr(e, "name", None)
|
||||
if missing_module and missing_module != pattern and not pattern.endswith(f".{missing_module}"):
|
||||
# The module exists but has an import error (missing dependency)
|
||||
logger.warning(f"Error importing {pattern}: {e}")
|
||||
return None, e
|
||||
# The module pattern itself doesn't exist - this is expected, try next pattern
|
||||
logger.debug(f"Import pattern {pattern} not found")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
# Capture the actual error for better error messages
|
||||
logger.warning(f"Error importing {pattern}: {e}")
|
||||
return None, e
|
||||
|
||||
def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None:
|
||||
"""Load module directly from file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to Python file
|
||||
module_name: Name to assign to module
|
||||
|
||||
Returns:
|
||||
Loaded module or None if failed
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module # Add to sys.modules for proper imports
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
logger.debug(f"Successfully loaded module from {file_path}")
|
||||
return module
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading module from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any:
|
||||
"""Find agent or workflow entity in a loaded module.
|
||||
|
||||
Args:
|
||||
module: Loaded Python module
|
||||
entity_id: Expected entity identifier to register with
|
||||
module_path: Path to module for metadata
|
||||
|
||||
Returns:
|
||||
Loaded entity object, or None if not found
|
||||
"""
|
||||
# Look for explicit variable names first
|
||||
candidates = [
|
||||
("agent", getattr(module, "agent", None)),
|
||||
("workflow", getattr(module, "workflow", None)),
|
||||
]
|
||||
|
||||
for obj_type, obj in candidates:
|
||||
if obj is None:
|
||||
continue
|
||||
|
||||
if self._is_valid_entity(obj, obj_type):
|
||||
# Register with the correct entity_id (from directory name)
|
||||
# Store the object directly in _loaded_objects so we can return it
|
||||
self._loaded_objects[entity_id] = obj
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
|
||||
"""Check if object is a valid agent or workflow using duck typing.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
expected_type: Expected type ("agent" or "workflow")
|
||||
|
||||
Returns:
|
||||
True if object is valid for the expected type
|
||||
"""
|
||||
if expected_type == "agent":
|
||||
return self._is_valid_agent(obj)
|
||||
if expected_type == "workflow":
|
||||
return self._is_valid_workflow(obj)
|
||||
return False
|
||||
|
||||
def _is_valid_agent(self, obj: Any) -> bool:
|
||||
"""Check if object is a valid Agent Framework agent.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
|
||||
Returns:
|
||||
True if object appears to be a valid agent
|
||||
"""
|
||||
try:
|
||||
# Try to import SupportsAgentRun for proper type checking
|
||||
try:
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
if isinstance(obj, SupportsAgentRun):
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback to duck typing for agent protocol
|
||||
# Agent must have run() method, plus id and name
|
||||
has_run = hasattr(obj, "run")
|
||||
if has_run and hasattr(obj, "id") and hasattr(obj, "name"):
|
||||
return True
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _is_valid_workflow(self, obj: Any) -> bool:
|
||||
"""Check if object is a valid Agent Framework workflow.
|
||||
|
||||
Args:
|
||||
obj: Object to validate
|
||||
|
||||
Returns:
|
||||
True if object appears to be a valid workflow
|
||||
"""
|
||||
# Check for workflow - must have run (streaming via stream=True) and executors
|
||||
has_run = hasattr(obj, "run")
|
||||
return has_run and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
|
||||
|
||||
async def _register_entity_from_object(
|
||||
self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
|
||||
) -> None:
|
||||
"""Register an entity from a live object.
|
||||
|
||||
Args:
|
||||
obj: Entity object
|
||||
obj_type: Type of entity ("agent" or "workflow")
|
||||
module_path: Path to module for metadata
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
"""
|
||||
try:
|
||||
# Generate entity ID with source information
|
||||
entity_id = self._generate_entity_id(obj, obj_type, source)
|
||||
|
||||
# Extract metadata from the live object with improved fallback naming
|
||||
name = getattr(obj, "name", None)
|
||||
if not name:
|
||||
# Use class name as it's more readable than UUID
|
||||
class_name = obj.__class__.__name__
|
||||
name = f"{obj_type.title()} {class_name}"
|
||||
description = getattr(obj, "description", None)
|
||||
tools = await self._extract_tools_from_object(obj, obj_type)
|
||||
|
||||
# Create EntityInfo
|
||||
tools_union: list[str | dict[str, Any]] | None = None
|
||||
if tools:
|
||||
tools_union = [tool for tool in tools]
|
||||
|
||||
# Extract agent-specific fields (for agents only)
|
||||
instructions = None
|
||||
model = None
|
||||
chat_client_type = None
|
||||
context_provider_list = None
|
||||
middlewares_list = None
|
||||
|
||||
if obj_type == "agent":
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
agent_meta = extract_agent_metadata(obj)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_provider_list = agent_meta["context_provider"]
|
||||
middlewares_list = agent_meta["middleware"]
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
type=obj_type,
|
||||
name=name,
|
||||
framework="agent_framework",
|
||||
description=description,
|
||||
tools=tools_union,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
chat_client_type=chat_client_type,
|
||||
context_provider=context_provider_list,
|
||||
middleware=middlewares_list,
|
||||
metadata={
|
||||
"module_path": module_path,
|
||||
"entity_type": obj_type,
|
||||
"source": source,
|
||||
"class_name": type(obj).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
# Register the entity
|
||||
self.register_entity(entity_id, entity_info, obj)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering entity from {source}: {e}")
|
||||
|
||||
async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
|
||||
"""Extract tool/executor names from a live object.
|
||||
|
||||
Args:
|
||||
obj: Entity object
|
||||
obj_type: Type of entity
|
||||
|
||||
Returns:
|
||||
List of tool/executor names
|
||||
"""
|
||||
tools: list[str] = []
|
||||
|
||||
try:
|
||||
if obj_type == "agent":
|
||||
chat_options = getattr(obj, "default_options", None)
|
||||
chat_options_tools: object | None = None
|
||||
if isinstance(chat_options, dict):
|
||||
chat_options_dict = cast(dict[str, Any], chat_options)
|
||||
chat_options_tools = chat_options_dict.get("tools")
|
||||
|
||||
if chat_options_tools is not None:
|
||||
tool_iterable: list[object] = (
|
||||
cast(list[object], chat_options_tools)
|
||||
if isinstance(chat_options_tools, list)
|
||||
else [chat_options_tools]
|
||||
)
|
||||
for tool_obj in tool_iterable:
|
||||
tool_name = getattr(tool_obj, "__name__", None)
|
||||
if isinstance(tool_name, str):
|
||||
tools.append(tool_name)
|
||||
continue
|
||||
|
||||
named_tool = getattr(tool_obj, "name", None)
|
||||
if isinstance(named_tool, str):
|
||||
tools.append(named_tool)
|
||||
else:
|
||||
tools.append(str(tool_obj))
|
||||
else:
|
||||
agent_tools = getattr(obj, "tools", None)
|
||||
if isinstance(agent_tools, list):
|
||||
for tool_obj in cast(list[object], agent_tools):
|
||||
tool_name = getattr(tool_obj, "__name__", None)
|
||||
if isinstance(tool_name, str):
|
||||
tools.append(tool_name)
|
||||
continue
|
||||
|
||||
named_tool = getattr(tool_obj, "name", None)
|
||||
if isinstance(named_tool, str):
|
||||
tools.append(named_tool)
|
||||
else:
|
||||
tools.append(str(tool_obj))
|
||||
|
||||
elif obj_type == "workflow":
|
||||
if hasattr(obj, "get_executors_list"):
|
||||
executor_objects = obj.get_executors_list()
|
||||
if isinstance(executor_objects, list):
|
||||
for executor_obj in cast(list[object], executor_objects):
|
||||
tools.append(str(getattr(executor_obj, "id", executor_obj)))
|
||||
elif hasattr(obj, "executors"):
|
||||
executors = obj.executors
|
||||
if isinstance(executors, list):
|
||||
for executor_obj in cast(list[object], executors):
|
||||
tools.append(str(getattr(executor_obj, "id", executor_obj)))
|
||||
elif isinstance(executors, dict):
|
||||
executors_dict = cast(dict[str, Any], executors)
|
||||
for key_obj in executors_dict:
|
||||
tools.append(str(key_obj))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
|
||||
|
||||
return tools
|
||||
|
||||
def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
|
||||
"""Generate unique entity ID with UUID suffix for collision resistance.
|
||||
|
||||
Args:
|
||||
entity: Entity object
|
||||
entity_type: Type of entity (agent, workflow, etc.)
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
|
||||
Returns:
|
||||
Unique entity ID with format: {type}_{source}_{name}_{uuid}
|
||||
"""
|
||||
import re
|
||||
|
||||
# Extract base name with priority: name -> id -> class_name
|
||||
if hasattr(entity, "name") and entity.name:
|
||||
base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
|
||||
elif hasattr(entity, "id") and entity.id:
|
||||
base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
|
||||
elif hasattr(entity, "__class__"):
|
||||
class_name = entity.__class__.__name__
|
||||
# Convert CamelCase to kebab-case
|
||||
base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
|
||||
else:
|
||||
base_name = "entity"
|
||||
|
||||
# Generate full UUID for guaranteed uniqueness
|
||||
full_uuid = uuid.uuid4().hex
|
||||
|
||||
return f"{entity_type}_{source}_{base_name}_{full_uuid}"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI integration for DevUI - proxy support for OpenAI Responses API."""
|
||||
|
||||
from ._executor import OpenAIExecutor
|
||||
|
||||
__all__ = [
|
||||
"OpenAIExecutor",
|
||||
]
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Executor - proxies requests to OpenAI Responses API.
|
||||
|
||||
This executor mirrors the AgentFrameworkExecutor interface but routes
|
||||
requests to OpenAI's API instead of executing local entities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from openai import APIStatusError, AsyncOpenAI, AsyncStream, AuthenticationError, PermissionDeniedError, RateLimitError
|
||||
from openai.types.responses import Response, ResponseStreamEvent
|
||||
|
||||
from .._conversations import ConversationStore
|
||||
from ..models import AgentFrameworkRequest, OpenAIResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_error_details(body: Any) -> tuple[str | None, str | None, str | None]:
|
||||
"""Extract typed OpenAI error fields from error body payload."""
|
||||
if not isinstance(body, dict):
|
||||
return None, None, None
|
||||
|
||||
error_dict: dict[str, Any] = body.get("error") # type: ignore[assignment, reportUnknownVariableType]
|
||||
if not isinstance(error_dict, dict):
|
||||
return None, None, None
|
||||
|
||||
message = error_dict.get("message")
|
||||
error_type = error_dict.get("type")
|
||||
code = error_dict.get("code")
|
||||
|
||||
return (
|
||||
message if isinstance(message, str) else None,
|
||||
error_type if isinstance(error_type, str) else None,
|
||||
code if isinstance(code, str) else None,
|
||||
)
|
||||
|
||||
|
||||
class OpenAIExecutor:
|
||||
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
|
||||
|
||||
This executor provides the same interface as AgentFrameworkExecutor but proxies
|
||||
requests to OpenAI's Responses API instead of executing local entities.
|
||||
|
||||
Key features:
|
||||
- Same execute_streaming() and execute_sync() interface
|
||||
- Shares ConversationStore with local executor
|
||||
- Configured via OPENAI_API_KEY environment variable
|
||||
- Supports all OpenAI Responses API parameters
|
||||
"""
|
||||
|
||||
def __init__(self, conversation_store: ConversationStore):
|
||||
"""Initialize OpenAI executor.
|
||||
|
||||
Args:
|
||||
conversation_store: Shared conversation store (works for both local and OpenAI)
|
||||
"""
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
# Load configuration from environment
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
self._client: AsyncOpenAI | None = None
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if OpenAI executor is properly configured.
|
||||
|
||||
Returns:
|
||||
True if OPENAI_API_KEY is set
|
||||
"""
|
||||
return self.api_key is not None
|
||||
|
||||
def _get_client(self) -> AsyncOpenAI:
|
||||
"""Get or create OpenAI async client.
|
||||
|
||||
Returns:
|
||||
AsyncOpenAI client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If OPENAI_API_KEY not configured
|
||||
"""
|
||||
if self._client is None:
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||||
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
logger.debug(f"Created OpenAI client with base_url: {self.base_url}")
|
||||
|
||||
return self._client
|
||||
|
||||
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any]:
|
||||
"""Execute request via OpenAI and stream results in OpenAI format.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_streaming() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Yields:
|
||||
OpenAI ResponseStreamEvent objects (already in correct format!)
|
||||
"""
|
||||
if not self.is_configured:
|
||||
logger.error("OpenAI executor not configured (missing OPENAI_API_KEY)")
|
||||
# Emit proper response.failed event
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": "OpenAI not configured on server. Set OPENAI_API_KEY environment variable.",
|
||||
"type": "configuration_error",
|
||||
"code": "openai_not_configured",
|
||||
},
|
||||
},
|
||||
}
|
||||
return
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields that OpenAI doesn't recognize
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Conversation ID is now from OpenAI (created via /v1/conversations proxy)
|
||||
# so we can pass it through!
|
||||
|
||||
# Force streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API: model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns AsyncStream[ResponseStreamEvent]
|
||||
stream: AsyncStream[ResponseStreamEvent] = await client.responses.create(
|
||||
**params,
|
||||
stream=True, # Force streaming
|
||||
)
|
||||
|
||||
# Yield events directly - they're already ResponseStreamEvent objects!
|
||||
# No conversion needed - OpenAI SDK returns proper typed objects
|
||||
async for event in stream:
|
||||
yield event
|
||||
|
||||
except AuthenticationError as e:
|
||||
# 401 - Invalid API key or authentication issue
|
||||
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "authentication_error",
|
||||
"code": code or "invalid_api_key",
|
||||
},
|
||||
},
|
||||
}
|
||||
except PermissionDeniedError as e:
|
||||
# 403 - Permission denied
|
||||
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "permission_denied",
|
||||
"code": code or "insufficient_permissions",
|
||||
},
|
||||
},
|
||||
}
|
||||
except RateLimitError as e:
|
||||
# 429 - Rate limit exceeded
|
||||
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "rate_limit_error",
|
||||
"code": code or "rate_limit_exceeded",
|
||||
},
|
||||
},
|
||||
}
|
||||
except APIStatusError as e:
|
||||
# Other OpenAI API errors
|
||||
logger.error(f"OpenAI API error: {e}", exc_info=True)
|
||||
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": message or str(e),
|
||||
"type": error_type or "api_error",
|
||||
"code": code or "unknown_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
# Catch-all for unexpected errors
|
||||
logger.error(f"Unexpected error in OpenAI proxy: {e}", exc_info=True)
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": f"resp_{os.urandom(16).hex()}",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": f"Unexpected error: {e!s}",
|
||||
"type": "internal_error",
|
||||
"code": "unexpected_error",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
|
||||
"""Execute request via OpenAI and return complete response.
|
||||
|
||||
This mirrors AgentFrameworkExecutor.execute_sync() interface.
|
||||
|
||||
Args:
|
||||
request: Request to execute
|
||||
|
||||
Returns:
|
||||
Final OpenAI Response object
|
||||
|
||||
Raises:
|
||||
ValueError: If OpenAI not configured
|
||||
Exception: If OpenAI API call fails
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("OpenAI not configured on server. Set OPENAI_API_KEY environment variable.")
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
# Convert AgentFrameworkRequest to OpenAI params
|
||||
params = request.to_openai_params()
|
||||
|
||||
# Remove DevUI-specific fields
|
||||
params.pop("extra_body", None)
|
||||
|
||||
# Force non-streaming mode (remove if already present to avoid duplicate)
|
||||
params.pop("stream", None)
|
||||
|
||||
logger.info(f"🔀 Proxying to OpenAI Responses API (non-streaming): model={params.get('model')}")
|
||||
logger.debug(f"Request params: {params}")
|
||||
|
||||
# Call OpenAI Responses API - returns Response object
|
||||
response: Response = await client.responses.create(
|
||||
**params,
|
||||
stream=False, # Force non-streaming
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI proxy error: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the OpenAI client and release resources."""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
logger.debug("Closed OpenAI client")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Session management for agent execution tracking."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestRecord(TypedDict):
|
||||
"""Tracked execution request data."""
|
||||
|
||||
id: str
|
||||
timestamp: datetime
|
||||
entity_id: str
|
||||
executor: str
|
||||
input: Any
|
||||
model: str
|
||||
stream: bool
|
||||
execution_time: NotRequired[float]
|
||||
status: NotRequired[str]
|
||||
|
||||
|
||||
class SessionData(TypedDict):
|
||||
"""Stored session state."""
|
||||
|
||||
id: str
|
||||
created_at: datetime
|
||||
requests: list[RequestRecord]
|
||||
context: dict[str, Any]
|
||||
active: bool
|
||||
|
||||
|
||||
SessionSummary = dict[str, Any]
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages execution sessions for tracking requests and context."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the session manager."""
|
||||
self.sessions: dict[str, SessionData] = {}
|
||||
|
||||
def create_session(self, session_id: str | None = None) -> str:
|
||||
"""Create a new execution session.
|
||||
|
||||
Args:
|
||||
session_id: Optional session ID, if not provided a new one is generated
|
||||
|
||||
Returns:
|
||||
Session ID
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
self.sessions[session_id] = {
|
||||
"id": session_id,
|
||||
"created_at": datetime.now(),
|
||||
"requests": [],
|
||||
"context": {},
|
||||
"active": True,
|
||||
}
|
||||
|
||||
logger.debug(f"Created session: {session_id}")
|
||||
return session_id
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData | None:
|
||||
"""Get session information.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Session data or None if not found
|
||||
"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def close_session(self, session_id: str) -> None:
|
||||
"""Close and cleanup a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to close
|
||||
"""
|
||||
if session_id in self.sessions:
|
||||
self.sessions[session_id]["active"] = False
|
||||
logger.debug(f"Closed session: {session_id}")
|
||||
|
||||
def add_request_record(
|
||||
self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str
|
||||
) -> str:
|
||||
"""Add a request record to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
entity_id: ID of the entity being executed
|
||||
executor_name: Name of the executor
|
||||
request_input: Input for the request
|
||||
model: Model name
|
||||
|
||||
Returns:
|
||||
Request ID
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return ""
|
||||
|
||||
request_record: RequestRecord = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"timestamp": datetime.now(),
|
||||
"entity_id": entity_id,
|
||||
"executor": executor_name,
|
||||
"input": request_input,
|
||||
"model": model,
|
||||
"stream": True,
|
||||
}
|
||||
session["requests"].append(request_record)
|
||||
return request_record["id"]
|
||||
|
||||
def update_request_record(self, session_id: str, request_id: str, updates: dict[str, Any]) -> None:
|
||||
"""Update a request record in a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
request_id: Request ID to update
|
||||
updates: Dictionary of updates to apply
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
for request in session["requests"]:
|
||||
if request["id"] == request_id:
|
||||
request_data = cast(dict[str, Any], request)
|
||||
request_data.update(updates)
|
||||
break
|
||||
|
||||
def get_session_history(self, session_id: str) -> SessionSummary | None:
|
||||
"""Get session execution history.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Session history or None if not found
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"created_at": session["created_at"].isoformat(),
|
||||
"active": session["active"],
|
||||
"request_count": len(session["requests"]),
|
||||
"requests": [
|
||||
{
|
||||
"id": req["id"],
|
||||
"timestamp": req["timestamp"].isoformat(),
|
||||
"entity_id": req["entity_id"],
|
||||
"executor": req["executor"],
|
||||
"model": req["model"],
|
||||
"input_length": len(str(req["input"])) if req["input"] else 0,
|
||||
"execution_time": req.get("execution_time"),
|
||||
"status": req.get("status", "unknown"),
|
||||
}
|
||||
for req in session["requests"]
|
||||
],
|
||||
}
|
||||
|
||||
def get_active_sessions(self) -> list[SessionSummary]:
|
||||
"""Get list of active sessions.
|
||||
|
||||
Returns:
|
||||
List of active session summaries
|
||||
"""
|
||||
active_sessions: list[SessionSummary] = []
|
||||
|
||||
for session_id, session in self.sessions.items():
|
||||
if session["active"]:
|
||||
active_sessions.append({
|
||||
"session_id": session_id,
|
||||
"created_at": session["created_at"].isoformat(),
|
||||
"request_count": len(session["requests"]),
|
||||
"last_activity": (
|
||||
session["requests"][-1]["timestamp"].isoformat()
|
||||
if session["requests"]
|
||||
else session["created_at"].isoformat()
|
||||
),
|
||||
})
|
||||
|
||||
return active_sessions
|
||||
|
||||
def cleanup_old_sessions(self, max_age_hours: int = 24) -> None:
|
||||
"""Cleanup old sessions to prevent memory leaks.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age of sessions to keep in hours
|
||||
"""
|
||||
cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
|
||||
|
||||
sessions_to_remove: list[str] = []
|
||||
for session_id, session in self.sessions.items():
|
||||
if session["created_at"].timestamp() < cutoff_time:
|
||||
sessions_to_remove.append(session_id)
|
||||
|
||||
for session_id in sessions_to_remove:
|
||||
del self.sessions[session_id]
|
||||
logger.debug(f"Cleaned up old session: {session_id}")
|
||||
|
||||
if sessions_to_remove:
|
||||
logger.info(f"Cleaned up {len(sessions_to_remove)} old sessions")
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Simplified tracing integration for Agent Framework Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Generator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
||||
|
||||
from .models import ResponseTraceEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleTraceCollector(SpanExporter):
|
||||
"""Simple trace collector that captures spans for direct yielding."""
|
||||
|
||||
def __init__(self, response_id: str | None = None, entity_id: str | None = None) -> None:
|
||||
"""Initialize trace collector.
|
||||
|
||||
Args:
|
||||
response_id: Response identifier for grouping traces by turn
|
||||
entity_id: Entity identifier for context
|
||||
"""
|
||||
self.response_id = response_id
|
||||
self.entity_id = entity_id
|
||||
self.collected_events: list[ResponseTraceEvent] = []
|
||||
|
||||
def export(self, spans: Sequence[Any]) -> SpanExportResult:
|
||||
"""Collect spans as trace events.
|
||||
|
||||
Args:
|
||||
spans: Sequence of OpenTelemetry spans
|
||||
|
||||
Returns:
|
||||
SpanExportResult indicating success
|
||||
"""
|
||||
logger.debug(f"SimpleTraceCollector received {len(spans)} spans")
|
||||
|
||||
try:
|
||||
for span in spans:
|
||||
trace_event = self._convert_span_to_trace_event(span)
|
||||
if trace_event:
|
||||
self.collected_events.append(trace_event)
|
||||
logger.debug(f"Collected trace event: {span.name}")
|
||||
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting trace spans: {e}")
|
||||
return SpanExportResult.FAILURE
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
"""Force flush spans (no-op for simple collection)."""
|
||||
return True
|
||||
|
||||
def get_pending_events(self) -> list[ResponseTraceEvent]:
|
||||
"""Get and clear pending trace events.
|
||||
|
||||
Returns:
|
||||
List of collected trace events, clearing the internal list
|
||||
"""
|
||||
events = self.collected_events.copy()
|
||||
self.collected_events.clear()
|
||||
return events
|
||||
|
||||
def _convert_span_to_trace_event(self, span: Any) -> ResponseTraceEvent | None:
|
||||
"""Convert OpenTelemetry span to ResponseTraceEvent.
|
||||
|
||||
Args:
|
||||
span: OpenTelemetry span
|
||||
|
||||
Returns:
|
||||
ResponseTraceEvent or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
start_time = span.start_time / 1_000_000_000 # Convert from nanoseconds
|
||||
end_time = span.end_time / 1_000_000_000 if span.end_time else None
|
||||
duration_ms = ((end_time - start_time) * 1000) if end_time else None
|
||||
|
||||
# Build trace data
|
||||
trace_data = {
|
||||
"type": "trace_span",
|
||||
"span_id": str(span.context.span_id),
|
||||
"trace_id": str(span.context.trace_id),
|
||||
"parent_span_id": str(span.parent.span_id) if span.parent else None,
|
||||
"operation_name": span.name,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"duration_ms": duration_ms,
|
||||
"attributes": dict(span.attributes) if span.attributes else {},
|
||||
"status": str(span.status.status_code) if hasattr(span, "status") else "OK",
|
||||
"response_id": self.response_id,
|
||||
"entity_id": self.entity_id,
|
||||
}
|
||||
|
||||
# Add events if available
|
||||
if hasattr(span, "events") and span.events:
|
||||
trace_data["events"] = [
|
||||
{
|
||||
"name": event.name,
|
||||
"timestamp": event.timestamp / 1_000_000_000,
|
||||
"attributes": dict(event.attributes) if event.attributes else {},
|
||||
}
|
||||
for event in span.events
|
||||
]
|
||||
|
||||
# Add error information if span failed
|
||||
if hasattr(span, "status") and span.status.status_code.name == "ERROR":
|
||||
trace_data["error"] = span.status.description or "Unknown error"
|
||||
|
||||
return ResponseTraceEvent(type="trace_event", data=trace_data, timestamp=datetime.now().isoformat())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert span {getattr(span, 'name', 'unknown')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_traces(response_id: str | None = None, entity_id: str | None = None) -> Generator[SimpleTraceCollector]:
|
||||
"""Context manager to capture traces during execution.
|
||||
|
||||
Args:
|
||||
response_id: Response identifier for grouping traces by turn
|
||||
entity_id: Entity identifier for context
|
||||
|
||||
Yields:
|
||||
SimpleTraceCollector instance to get trace events from
|
||||
"""
|
||||
collector = SimpleTraceCollector(response_id, entity_id)
|
||||
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
|
||||
# Get current tracer provider and add our collector
|
||||
provider = trace.get_tracer_provider()
|
||||
processor = SimpleSpanProcessor(collector)
|
||||
|
||||
# Check if this is a real TracerProvider (not the default NoOpTracerProvider)
|
||||
if isinstance(provider, TracerProvider):
|
||||
provider.add_span_processor(processor)
|
||||
logger.debug(f"Added trace collector to TracerProvider for response: {response_id}, entity: {entity_id}")
|
||||
|
||||
try:
|
||||
yield collector
|
||||
finally:
|
||||
# Clean up - shutdown processor
|
||||
try:
|
||||
processor.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error shutting down processor: {e}")
|
||||
else:
|
||||
logger.warning(f"No real TracerProvider available, got: {type(provider)}")
|
||||
yield collector
|
||||
|
||||
except ImportError:
|
||||
logger.debug("OpenTelemetry not available")
|
||||
yield collector
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up trace capture: {e}")
|
||||
yield collector
|
||||
@@ -0,0 +1,801 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Utility functions for DevUI."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, Union, cast, get_args, get_origin, get_type_hints
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _string_key_dict(value: object) -> dict[str, Any] | None:
|
||||
"""Cast value to a dict."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return cast(dict[str, Any], value)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Metadata Extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
|
||||
"""Extract agent-specific metadata from an entity object.
|
||||
|
||||
Args:
|
||||
entity_object: Agent Framework agent object
|
||||
|
||||
Returns:
|
||||
Dictionary with agent metadata: instructions, model, chat_client_type,
|
||||
context_providers, and middleware
|
||||
"""
|
||||
metadata = {
|
||||
"instructions": None,
|
||||
"model": None,
|
||||
"chat_client_type": None,
|
||||
"context_provider": None,
|
||||
"middleware": None,
|
||||
}
|
||||
|
||||
# Try to get instructions
|
||||
if hasattr(entity_object, "default_options"):
|
||||
chat_opts = entity_object.default_options
|
||||
chat_opts_dict = _string_key_dict(chat_opts)
|
||||
if chat_opts_dict is not None:
|
||||
if "instructions" in chat_opts_dict:
|
||||
metadata["instructions"] = chat_opts_dict.get("instructions")
|
||||
elif hasattr(chat_opts, "instructions"):
|
||||
metadata["instructions"] = chat_opts.instructions
|
||||
|
||||
# Try to get model - check both default_options and client
|
||||
if hasattr(entity_object, "default_options"):
|
||||
chat_opts = entity_object.default_options
|
||||
chat_opts_dict = _string_key_dict(chat_opts)
|
||||
if chat_opts_dict is not None:
|
||||
model = chat_opts_dict.get("model")
|
||||
if model:
|
||||
metadata["model"] = model
|
||||
elif hasattr(chat_opts, "model") and chat_opts.model:
|
||||
metadata["model"] = chat_opts.model
|
||||
if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model"):
|
||||
metadata["model"] = entity_object.client.model
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(entity_object, "client"):
|
||||
metadata["chat_client_type"] = entity_object.client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(entity_object, "context_provider")
|
||||
and entity_object.context_provider
|
||||
and hasattr(entity_object.context_provider, "__class__")
|
||||
):
|
||||
metadata["context_provider"] = [entity_object.context_provider.__class__.__name__] # type: ignore
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(entity_object, "middleware") and entity_object.middleware:
|
||||
middlewares_list: list[str] = []
|
||||
for m in entity_object.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middlewares_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middlewares_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middlewares_list.append(str(m))
|
||||
metadata["middleware"] = middlewares_list # type: ignore
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Input Type Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_executor_message_types(executor: Any) -> list[Any]:
|
||||
"""Extract declared input types for the given executor.
|
||||
|
||||
Args:
|
||||
executor: Workflow executor object
|
||||
|
||||
Returns:
|
||||
List of message types that the executor accepts
|
||||
"""
|
||||
message_types: list[Any] = []
|
||||
|
||||
try:
|
||||
input_types = getattr(executor, "input_types", None)
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to access executor input_types: {exc}")
|
||||
else:
|
||||
if input_types:
|
||||
message_types = list(input_types)
|
||||
|
||||
if not message_types and hasattr(executor, "_handlers"):
|
||||
try:
|
||||
handlers = executor._handlers
|
||||
if isinstance(handlers, dict):
|
||||
message_types = list(handlers.keys()) # type: ignore[arg-type]
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to read executor handlers: {exc}")
|
||||
|
||||
return message_types
|
||||
|
||||
|
||||
def _contains_chat_message(type_hint: Any) -> bool:
|
||||
"""Check whether the provided type hint directly or indirectly references Message."""
|
||||
if type_hint is Message:
|
||||
return True
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
if origin in (list, tuple):
|
||||
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
|
||||
|
||||
if origin in (Union, UnionType):
|
||||
return any(_contains_chat_message(arg) for arg in get_args(type_hint))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_list_message_type(type_hint: Any) -> bool:
|
||||
"""Return True if type_hint is exactly list[Message]."""
|
||||
return get_origin(type_hint) is list and bool(get_args(type_hint)) and get_args(type_hint)[0] is Message
|
||||
|
||||
|
||||
def _find_chat_message_type(type_hint: Any) -> Any | None:
|
||||
"""Return ``list[Message]`` or ``Message`` if present in type_hint, else None.
|
||||
|
||||
Recursively inspects union members so that a single-element ``message_types``
|
||||
list like ``[dict | str | list[Message] | ...]`` (the real ``JoinExecutor``
|
||||
form) is handled correctly. ``list[Message]`` takes priority over bare
|
||||
``Message``.
|
||||
"""
|
||||
if _is_list_message_type(type_hint):
|
||||
return type_hint
|
||||
if type_hint is Message:
|
||||
return Message
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
if origin in (Union, UnionType):
|
||||
fallback = None
|
||||
for arg in get_args(type_hint):
|
||||
found = _find_chat_message_type(arg)
|
||||
if found is None:
|
||||
continue
|
||||
if _is_list_message_type(found):
|
||||
return found # list[Message] wins immediately
|
||||
if fallback is None:
|
||||
fallback = found # bare Message — keep searching
|
||||
return fallback
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def select_primary_input_type(message_types: list[Any]) -> Any | None:
|
||||
"""Choose the most user-friendly input type for workflow inputs.
|
||||
|
||||
Prefers Message (or containers thereof) and then falls back to primitives.
|
||||
When the executor's union contains ``list[Message]`` (as the declarative
|
||||
entry ``JoinExecutor`` does), that type is returned so that
|
||||
``parse_input_for_type`` can wrap the user's text in a list before
|
||||
dispatching — avoiding a "cannot handle message of type Message" error.
|
||||
|
||||
``message_types`` may contain full union types as single elements (the real
|
||||
``JoinExecutor`` emits ``[dict | str | list[Message] | ...]``), so each
|
||||
element is searched recursively.
|
||||
|
||||
Args:
|
||||
message_types: List of possible message types
|
||||
|
||||
Returns:
|
||||
Selected primary input type, or None if list is empty
|
||||
"""
|
||||
if not message_types:
|
||||
return None
|
||||
|
||||
# First pass: search each type (including union members) for list[Message] or Message.
|
||||
for message_type in message_types:
|
||||
found = _find_chat_message_type(message_type)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
# Second pass: broader fallback for deeply nested or unusual containers.
|
||||
for message_type in message_types:
|
||||
if _contains_chat_message(message_type):
|
||||
return Message
|
||||
|
||||
preferred = (str, dict)
|
||||
|
||||
for candidate in preferred:
|
||||
for message_type in message_types:
|
||||
if message_type is candidate:
|
||||
return candidate
|
||||
origin = get_origin(message_type)
|
||||
if origin is candidate:
|
||||
return candidate
|
||||
|
||||
return message_types[0]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Type System Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def is_serialization_mixin(cls: type) -> bool:
|
||||
"""Check if class is a SerializationMixin subclass.
|
||||
|
||||
Args:
|
||||
cls: Class to check
|
||||
|
||||
Returns:
|
||||
True if class is a SerializationMixin subclass
|
||||
"""
|
||||
try:
|
||||
from agent_framework._serialization import SerializationMixin
|
||||
|
||||
return isinstance(cls, type) and issubclass(cls, SerializationMixin)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _type_to_schema(type_hint: Any, field_name: str) -> dict[str, Any]:
|
||||
"""Convert a type hint to JSON schema.
|
||||
|
||||
Args:
|
||||
type_hint: Type hint to convert
|
||||
field_name: Name of the field (for documentation)
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
type_str = str(type_hint)
|
||||
|
||||
# Handle None/Optional
|
||||
if type_hint is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# Handle basic types
|
||||
if type_hint is str or "str" in type_str:
|
||||
return {"type": "string"}
|
||||
if type_hint is int or "int" in type_str:
|
||||
return {"type": "integer"}
|
||||
if type_hint is float or "float" in type_str:
|
||||
return {"type": "number"}
|
||||
if type_hint is bool or "bool" in type_str:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# Handle Literal types (for enum-like values)
|
||||
if "Literal" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
return {"type": "string", "enum": list(args)}
|
||||
|
||||
# Handle Union/Optional
|
||||
if "Union" in type_str or "Optional" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
# Filter out None type
|
||||
non_none_args = [arg for arg in args if arg is not type(None)]
|
||||
if len(non_none_args) == 1:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
# Multiple types - pick first non-None
|
||||
if non_none_args:
|
||||
return _type_to_schema(non_none_args[0], field_name)
|
||||
|
||||
# Handle collections
|
||||
if "list" in type_str or "List" in type_str or "Sequence" in type_str:
|
||||
origin = get_origin(type_hint)
|
||||
if origin is not None:
|
||||
args = get_args(type_hint)
|
||||
if args:
|
||||
items_schema = _type_to_schema(args[0], field_name)
|
||||
return {"type": "array", "items": items_schema}
|
||||
return {"type": "array"}
|
||||
|
||||
if "dict" in type_str or "Dict" in type_str or "Mapping" in type_str:
|
||||
return {"type": "object"}
|
||||
|
||||
# Default fallback
|
||||
return {"type": "string", "description": f"Type: {type_hint}"}
|
||||
|
||||
|
||||
def generate_schema_from_serialization_mixin(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from SerializationMixin class.
|
||||
|
||||
Introspects the __init__ signature to extract parameter types and defaults.
|
||||
|
||||
Args:
|
||||
cls: SerializationMixin subclass
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
sig = inspect.signature(cls)
|
||||
|
||||
# Get type hints
|
||||
try:
|
||||
type_hints = get_type_hints(cls)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ("self", "kwargs"):
|
||||
continue
|
||||
|
||||
# Get type annotation
|
||||
param_type = type_hints.get(param_name, str)
|
||||
|
||||
# Generate schema for this parameter
|
||||
param_schema = _type_to_schema(param_type, param_name)
|
||||
properties[param_name] = param_schema
|
||||
|
||||
# Check if required (no default value, not VAR_KEYWORD)
|
||||
if param.default == inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
|
||||
required.append(param_name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
|
||||
"""Generate JSON schema from dataclass.
|
||||
|
||||
Args:
|
||||
cls: Dataclass type
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
if not is_dataclass(cls):
|
||||
return {"type": "object"}
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for field in fields(cls):
|
||||
# Generate schema for field type
|
||||
field_schema = _type_to_schema(field.type, field.name)
|
||||
properties[field.name] = field_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if field.default == field.default_factory: # No default
|
||||
required.append(field.name)
|
||||
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
|
||||
if required:
|
||||
schema["required"] = required
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def extract_response_type_from_executor(executor: Any, request_type: type) -> type | None:
|
||||
"""Extract the expected response type from an executor's response handler.
|
||||
|
||||
Looks for methods decorated with @response_handler that have signature:
|
||||
async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
|
||||
Args:
|
||||
executor: Executor object that should have a handler for the request type
|
||||
request_type: The request message type
|
||||
|
||||
Returns:
|
||||
The response type class, or None if not found
|
||||
"""
|
||||
try:
|
||||
# Introspect handler methods for @response_handler pattern
|
||||
for attr_name in dir(executor):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
attr = getattr(executor, attr_name, None)
|
||||
if not callable(attr):
|
||||
continue
|
||||
|
||||
# Get type hints for this method
|
||||
try:
|
||||
type_hints = get_type_hints(attr)
|
||||
|
||||
# Check for @response_handler pattern:
|
||||
# async def handler(self, original_request: RequestType, response: ResponseType, ctx)
|
||||
type_hint_params = {k: v for k, v in type_hints.items() if k not in ("self", "return")}
|
||||
|
||||
# Look for at least 2 parameters: original_request, response (ctx is optional)
|
||||
if len(type_hint_params) >= 2:
|
||||
param_items = list(type_hint_params.items())
|
||||
# First param should be original_request matching request_type
|
||||
_, first_param_type = param_items[0]
|
||||
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
|
||||
|
||||
# Check if first param matches request_type
|
||||
first_matches_request = first_param_type == request_type
|
||||
if not first_matches_request and isinstance(first_param_type, type):
|
||||
request_type_name = request_type.__name__
|
||||
first_matches_request = first_param_type.__name__ == request_type_name
|
||||
|
||||
# Verify we have a matching request type and valid response type (must be a type class)
|
||||
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
|
||||
response_type_class: type = second_param_type
|
||||
logger.debug(
|
||||
f"Found response type {response_type_class} for request {request_type} "
|
||||
f"via @response_handler"
|
||||
)
|
||||
return response_type_class
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get type hints for {attr_name}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract response type from executor: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_input_schema(input_type: type) -> dict[str, Any]:
|
||||
"""Generate JSON schema for workflow input type.
|
||||
|
||||
Supports multiple input types in priority order:
|
||||
0. list[Message] — rendered as a plain string input (DevUI presents a text
|
||||
box; the text is later wrapped in a list by parse_input_for_type)
|
||||
1. Built-in types (str, dict, int, etc.)
|
||||
2. Pydantic models (via model_json_schema)
|
||||
3. SerializationMixin classes (via __init__ introspection)
|
||||
4. Dataclasses (via fields introspection)
|
||||
5. Fallback to string
|
||||
|
||||
Args:
|
||||
input_type: Input type to generate schema for
|
||||
|
||||
Returns:
|
||||
JSON schema dict
|
||||
"""
|
||||
# 0. list[Message] — treat as simple text so DevUI shows a text box
|
||||
if _is_list_message_type(input_type):
|
||||
return {"type": "string"}
|
||||
|
||||
# 1. Built-in types
|
||||
if input_type is str:
|
||||
return {"type": "string"}
|
||||
if input_type is dict:
|
||||
return {"type": "object"}
|
||||
if input_type is int:
|
||||
return {"type": "integer"}
|
||||
if input_type is float:
|
||||
return {"type": "number"}
|
||||
if input_type is bool:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# 2. Pydantic models (legacy support)
|
||||
if hasattr(input_type, "model_json_schema"):
|
||||
return input_type.model_json_schema() # type: ignore
|
||||
|
||||
# 3. SerializationMixin classes (Message, etc.)
|
||||
if is_serialization_mixin(input_type):
|
||||
return generate_schema_from_serialization_mixin(input_type)
|
||||
|
||||
# 4. Dataclasses
|
||||
if is_dataclass(input_type):
|
||||
return generate_schema_from_dataclass(input_type)
|
||||
|
||||
# 5. Fallback to string
|
||||
type_name = input_type.__name__ if isinstance(input_type, type) else str(cast(Any, input_type))
|
||||
return {"type": "string", "description": f"Input type: {type_name}"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Input Parsing Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_input_for_type(input_data: Any, target_type: type) -> Any:
|
||||
"""Parse input data to match the target type.
|
||||
|
||||
Handles conversion from raw input (string, dict) to the expected type:
|
||||
- list[Message]: build a Message from the raw input and wrap it in a list
|
||||
- Built-in types: direct conversion
|
||||
- Pydantic models: use model_validate or model_validate_json
|
||||
- SerializationMixin: use from_dict or construct from string
|
||||
- Dataclasses: construct from dict
|
||||
|
||||
Args:
|
||||
input_data: Raw input data (string, dict, or already correct type)
|
||||
target_type: Expected type for the input
|
||||
|
||||
Returns:
|
||||
Parsed input matching target_type, or original input if parsing fails
|
||||
"""
|
||||
# list[Message]: generic aliases cannot be used with isinstance, handle first.
|
||||
if _is_list_message_type(target_type):
|
||||
raw: object = input_data
|
||||
if isinstance(raw, list):
|
||||
items = cast(list[object], raw)
|
||||
if all(isinstance(m, Message) for m in items):
|
||||
return items
|
||||
# Try to convert each item (serialized str/dict OpenAI message) to Message.
|
||||
converted: list[Message] = []
|
||||
ok = True
|
||||
for item in items:
|
||||
if isinstance(item, Message):
|
||||
converted.append(item)
|
||||
elif isinstance(item, str):
|
||||
converted.append(_build_message_from_legacy_payload(item))
|
||||
elif isinstance(item, dict):
|
||||
converted.append(_build_message_from_legacy_payload(cast(dict[str, Any], item)))
|
||||
else:
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return converted
|
||||
# A list never matches the Message/str/dict checks below; stringify directly.
|
||||
return [_build_message_from_legacy_payload(str(items))]
|
||||
if isinstance(raw, Message):
|
||||
return [raw]
|
||||
if isinstance(raw, str):
|
||||
return [_build_message_from_legacy_payload(raw)]
|
||||
parsed_dict = _string_key_dict(raw)
|
||||
if parsed_dict is not None:
|
||||
if parsed_dict and _looks_like_message_dict(parsed_dict):
|
||||
return [_build_message_from_legacy_payload(parsed_dict)]
|
||||
return raw
|
||||
return [_build_message_from_legacy_payload(str(raw))]
|
||||
|
||||
# If already correct type, return as-is
|
||||
if isinstance(input_data, target_type):
|
||||
return input_data
|
||||
|
||||
# Handle string input
|
||||
if isinstance(input_data, str):
|
||||
return _parse_string_input(input_data, target_type)
|
||||
|
||||
# Handle dict input
|
||||
parsed_dict = _string_key_dict(input_data)
|
||||
if parsed_dict is not None:
|
||||
return _parse_dict_input(parsed_dict, target_type)
|
||||
|
||||
# Fallback: return original
|
||||
return input_data
|
||||
|
||||
|
||||
def _looks_like_message_dict(d: dict[str, Any]) -> bool:
|
||||
"""Return True if *d* is a recognisable serialised Message payload.
|
||||
|
||||
Three recognised signatures (in priority order):
|
||||
1. ``type`` discriminator is literally ``"message"`` — output of ``Message.to_dict()``.
|
||||
2. Dict has a ``"role"`` key — all framework Message objects carry a role.
|
||||
3. Dict has exactly the single key ``"input"`` — DevUI ``WorkflowInputForm``
|
||||
submits ``{"input": "<user text>"}`` for workflows whose schema is ``{"type":
|
||||
"string"}``.
|
||||
|
||||
Anything else is treated as a structured workflow input and passed through
|
||||
unchanged, to be handled by the dict-union branch of the executor.
|
||||
"""
|
||||
if d.get("type") == "message":
|
||||
return True
|
||||
if "role" in d:
|
||||
return True
|
||||
return set(d.keys()) == {"input"}
|
||||
|
||||
|
||||
def _build_message_from_legacy_payload(input_data: str | dict[str, Any]) -> Message:
|
||||
"""Convert raw DevUI input into a framework Message.
|
||||
|
||||
This preserves DevUI compatibility for older payloads that still send
|
||||
``{"role": "...", "text": "..."}`` instead of the framework-native
|
||||
``{"role": "...", "contents": [...]}`` shape.
|
||||
"""
|
||||
if isinstance(input_data, str):
|
||||
return Message(role="user", contents=[input_data])
|
||||
|
||||
role = input_data.get("role", "user")
|
||||
role = role if isinstance(role, str) else str(role)
|
||||
|
||||
if "contents" in input_data:
|
||||
contents = input_data["contents"]
|
||||
else:
|
||||
contents = None
|
||||
for field in ("text", "message", "content", "input", "data"):
|
||||
if field in input_data:
|
||||
contents = input_data[field]
|
||||
break
|
||||
|
||||
if contents is None:
|
||||
contents_list: list[Any] = []
|
||||
elif isinstance(contents, list):
|
||||
contents_list = contents # type: ignore[reportUnknownVariableType]
|
||||
else:
|
||||
contents_list = [contents]
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
for field in (
|
||||
"author_name",
|
||||
"message_id",
|
||||
"additional_properties",
|
||||
"raw_representation",
|
||||
):
|
||||
if field in input_data:
|
||||
kwargs[field] = input_data[field]
|
||||
|
||||
return Message(role=role, contents=contents_list, **kwargs)
|
||||
|
||||
|
||||
def _parse_string_input(input_str: str, target_type: type) -> Any:
|
||||
"""Parse string input to target type.
|
||||
|
||||
Args:
|
||||
input_str: Input string
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original string
|
||||
"""
|
||||
# Built-in types
|
||||
if target_type is str:
|
||||
return input_str
|
||||
if target_type is int:
|
||||
try:
|
||||
return int(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is float:
|
||||
try:
|
||||
return float(input_str)
|
||||
except ValueError:
|
||||
return input_str
|
||||
elif target_type is bool:
|
||||
return input_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate_json"):
|
||||
try:
|
||||
# Try parsing as JSON first
|
||||
if input_str.strip().startswith("{"):
|
||||
return target_type.model_validate_json(input_str) # type: ignore
|
||||
|
||||
# Try common field names with the string value
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string input with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin (like Message)
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
if target_type is Message:
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
parsed_dict = _string_key_dict(data)
|
||||
if parsed_dict is not None:
|
||||
return _build_message_from_legacy_payload(parsed_dict)
|
||||
return _build_message_from_legacy_payload(input_str)
|
||||
|
||||
# Try parsing as JSON dict first
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(data) # type: ignore
|
||||
return target_type(**data)
|
||||
|
||||
# Try other common fields
|
||||
common_fields = ["text", "message", "content"]
|
||||
sig = inspect.signature(target_type)
|
||||
params = list(sig.parameters.keys())
|
||||
for field in common_fields:
|
||||
if field in params:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create SerializationMixin with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
# Try parsing as JSON
|
||||
if input_str.strip().startswith("{"):
|
||||
data = json.loads(input_str)
|
||||
return target_type(**data)
|
||||
|
||||
# Try common field names
|
||||
common_fields = ["text", "message", "content", "input", "data"]
|
||||
for field in common_fields:
|
||||
try:
|
||||
return target_type(**{field: input_str})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to create dataclass with field '{field}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse string as dataclass: {e}")
|
||||
|
||||
# Fallback: return original string
|
||||
return input_str
|
||||
|
||||
|
||||
def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
|
||||
"""Parse dict input to target type.
|
||||
|
||||
Args:
|
||||
input_dict: Input dictionary
|
||||
target_type: Target type
|
||||
|
||||
Returns:
|
||||
Parsed input or original dict
|
||||
"""
|
||||
# Handle primitive types - extract from common field names
|
||||
if target_type in (str, int, float, bool):
|
||||
try:
|
||||
# If it's already the right type, return as-is
|
||||
if isinstance(input_dict, target_type):
|
||||
return input_dict
|
||||
|
||||
# Try "input" field first (common for workflow inputs)
|
||||
if "input" in input_dict:
|
||||
return target_type(input_dict["input"])
|
||||
|
||||
# If single-key dict, extract the value
|
||||
if len(input_dict) == 1:
|
||||
value = next(iter(input_dict.values()))
|
||||
return target_type(value)
|
||||
|
||||
# Otherwise, return as-is
|
||||
return input_dict
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"Failed to convert dict to {target_type}: {e}")
|
||||
return input_dict
|
||||
|
||||
# If target is dict, return as-is
|
||||
if target_type is dict:
|
||||
return input_dict
|
||||
|
||||
# Pydantic models
|
||||
if hasattr(target_type, "model_validate"):
|
||||
try:
|
||||
return target_type.model_validate(input_dict) # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
|
||||
|
||||
# SerializationMixin
|
||||
if is_serialization_mixin(target_type):
|
||||
try:
|
||||
if target_type is Message:
|
||||
return _build_message_from_legacy_payload(input_dict)
|
||||
if hasattr(target_type, "from_dict"):
|
||||
return target_type.from_dict(input_dict) # type: ignore
|
||||
return target_type(**input_dict)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
|
||||
|
||||
# Dataclasses
|
||||
if is_dataclass(target_type):
|
||||
try:
|
||||
return target_type(**input_dict)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse dict as dataclass: {e}")
|
||||
|
||||
# Fallback: return original dict
|
||||
return input_dict
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework DevUI Models - OpenAI-compatible types and custom extensions."""
|
||||
|
||||
# Import discovery models
|
||||
# Import all OpenAI types directly from the openai package
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseReasoningTextDeltaEvent,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseUsage,
|
||||
ToolParam,
|
||||
)
|
||||
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
|
||||
from openai.types.shared import Metadata, ResponsesModel
|
||||
|
||||
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
|
||||
from ._openai_custom import (
|
||||
AgentFrameworkRequest,
|
||||
CustomResponseOutputItemAddedEvent,
|
||||
CustomResponseOutputItemDoneEvent,
|
||||
ExecutorActionItem,
|
||||
MetaResponse,
|
||||
OpenAIError,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseOutputData,
|
||||
ResponseOutputFile,
|
||||
ResponseOutputImage,
|
||||
ResponseTraceEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseWorkflowEventComplete,
|
||||
)
|
||||
|
||||
# Type alias for compatibility
|
||||
OpenAIResponse = Response
|
||||
|
||||
# Export all types for easy importing
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"Conversation",
|
||||
"ConversationDeletedResource",
|
||||
"ConversationItem",
|
||||
"CustomResponseOutputItemAddedEvent",
|
||||
"CustomResponseOutputItemDoneEvent",
|
||||
"Deployment",
|
||||
"DeploymentConfig",
|
||||
"DeploymentEvent",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"ExecutorActionItem",
|
||||
"InputTokensDetails",
|
||||
"MetaResponse",
|
||||
"Metadata",
|
||||
"OpenAIError",
|
||||
"OpenAIResponse",
|
||||
"OutputTokensDetails",
|
||||
"Response",
|
||||
"ResponseCompletedEvent",
|
||||
"ResponseErrorEvent",
|
||||
"ResponseFunctionCallArgumentsDeltaEvent",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseFunctionToolCall",
|
||||
"ResponseFunctionToolCallOutputItem",
|
||||
"ResponseInputParam",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseOutputItemAddedEvent",
|
||||
"ResponseOutputItemDoneEvent",
|
||||
"ResponseOutputMessage",
|
||||
"ResponseOutputText",
|
||||
"ResponseReasoningTextDeltaEvent",
|
||||
"ResponseStreamEvent",
|
||||
"ResponseTextDeltaEvent",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseUsage",
|
||||
"ResponseWorkflowEventComplete",
|
||||
"ResponsesModel",
|
||||
"ToolParam",
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Discovery API models for entity information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class EnvVarRequirement(BaseModel):
|
||||
"""Environment variable requirement for an entity."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
required: bool = True
|
||||
example: str | None = None
|
||||
|
||||
|
||||
class EntityInfo(BaseModel):
|
||||
"""Entity information for discovery and detailed views."""
|
||||
|
||||
# Always present (core entity data)
|
||||
id: str
|
||||
type: str # "agent", "workflow"
|
||||
name: str
|
||||
description: str | None = None
|
||||
framework: str
|
||||
tools: list[str | dict[str, Any]] | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
# Source information
|
||||
source: str = "directory" # "directory" or "in_memory"
|
||||
|
||||
# Environment variable requirements
|
||||
required_env_vars: list[EnvVarRequirement] | None = None
|
||||
|
||||
# Deployment support
|
||||
deployment_supported: bool = False # Whether entity can be deployed
|
||||
deployment_reason: str | None = None # Explanation of why/why not entity can be deployed
|
||||
|
||||
# Agent-specific fields (optional, populated when available)
|
||||
instructions: str | None = None
|
||||
model: str | None = None
|
||||
chat_client_type: str | None = None
|
||||
context_provider: list[str] | None = None
|
||||
middleware: list[str] | None = None
|
||||
|
||||
# Workflow-specific fields (populated only for detailed info requests)
|
||||
executors: list[str] | None = None
|
||||
workflow_dump: dict[str, Any] | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
input_type_name: str | None = None
|
||||
start_executor_id: str | None = None
|
||||
|
||||
|
||||
class DiscoveryResponse(BaseModel):
|
||||
"""Response model for entity discovery."""
|
||||
|
||||
entities: list[EntityInfo] = Field(default_factory=cast(Callable[..., list[EntityInfo]], list))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Deployment Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class DeploymentConfig(BaseModel):
|
||||
"""Configuration for deploying an entity."""
|
||||
|
||||
entity_id: str = Field(description="Entity ID to deploy")
|
||||
resource_group: str = Field(description="Azure resource group name")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(default="eastus", description="Azure region")
|
||||
ui_mode: str = Field(default="user", description="UI mode (user or developer)")
|
||||
ui_enabled: bool = Field(default=True, description="Whether to enable web interface")
|
||||
stream: bool = Field(default=True, description="Stream deployment events")
|
||||
|
||||
@field_validator("app_name")
|
||||
@classmethod
|
||||
def validate_app_name(cls, v: str) -> str:
|
||||
"""Validate Azure Container App name format.
|
||||
|
||||
Azure Container App names must:
|
||||
- Be 3-32 characters long
|
||||
- Contain only lowercase letters, numbers, and hyphens
|
||||
- Start with a lowercase letter
|
||||
- End with a lowercase letter or number
|
||||
- Not contain consecutive hyphens
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("app_name cannot be empty")
|
||||
|
||||
if len(v) < 3 or len(v) > 32:
|
||||
raise ValueError("app_name must be between 3 and 32 characters")
|
||||
|
||||
if not re.match(r"^[a-z][a-z0-9-]*[a-z0-9]$", v):
|
||||
raise ValueError(
|
||||
"app_name must start with a lowercase letter, "
|
||||
"end with a letter or number, and contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
if "--" in v:
|
||||
raise ValueError("app_name cannot contain consecutive hyphens")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("resource_group")
|
||||
@classmethod
|
||||
def validate_resource_group(cls, v: str) -> str:
|
||||
"""Validate Azure resource group name format.
|
||||
|
||||
Azure resource group names must:
|
||||
- Be 1-90 characters long
|
||||
- Contain only alphanumeric, underscore, parentheses, hyphen, period (except at end)
|
||||
- Not end with a period
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("resource_group cannot be empty")
|
||||
|
||||
if len(v) > 90:
|
||||
raise ValueError("resource_group must be 90 characters or less")
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9._()-]+$", v):
|
||||
raise ValueError(
|
||||
"resource_group can only contain alphanumeric characters, "
|
||||
"underscores, hyphens, periods, and parentheses"
|
||||
)
|
||||
|
||||
if v.endswith("."):
|
||||
raise ValueError("resource_group cannot end with a period")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("region")
|
||||
@classmethod
|
||||
def validate_region(cls, v: str) -> str:
|
||||
"""Validate Azure region format.
|
||||
|
||||
Validates that the region string is a reasonable format.
|
||||
Does not validate against the full list of Azure regions (which changes).
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("region cannot be empty")
|
||||
|
||||
if len(v) > 50:
|
||||
raise ValueError("region name too long")
|
||||
|
||||
# Azure regions are typically lowercase with no spaces (e.g., eastus, westeurope)
|
||||
if not re.match(r"^[a-z0-9]+$", v):
|
||||
raise ValueError("region must contain only lowercase letters and numbers (e.g., eastus, westeurope)")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("entity_id")
|
||||
@classmethod
|
||||
def validate_entity_id(cls, v: str) -> str:
|
||||
"""Validate entity_id format to prevent injection attacks."""
|
||||
if not v:
|
||||
raise ValueError("entity_id cannot be empty")
|
||||
|
||||
if len(v) > 256:
|
||||
raise ValueError("entity_id too long")
|
||||
|
||||
# Allow alphanumeric, hyphens, underscores, and periods
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", v):
|
||||
raise ValueError("entity_id contains invalid characters")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("ui_mode")
|
||||
@classmethod
|
||||
def validate_ui_mode(cls, v: str) -> str:
|
||||
"""Validate ui_mode is one of the allowed values."""
|
||||
if v not in ("user", "developer"):
|
||||
raise ValueError("ui_mode must be 'user' or 'developer'")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class DeploymentEvent(BaseModel):
|
||||
"""Real-time deployment event (SSE)."""
|
||||
|
||||
type: str = Field(description="Event type (e.g., deploy.validating, deploy.building)")
|
||||
message: str = Field(description="Human-readable message")
|
||||
url: str | None = Field(default=None, description="Deployment URL (on completion)")
|
||||
auth_token: str | None = Field(default=None, description="Auth token (on completion, shown once)")
|
||||
|
||||
|
||||
class Deployment(BaseModel):
|
||||
"""Deployment record."""
|
||||
|
||||
id: str = Field(description="Deployment ID (UUID)")
|
||||
entity_id: str = Field(description="Entity ID that was deployed")
|
||||
resource_group: str = Field(description="Azure resource group")
|
||||
app_name: str = Field(description="Azure Container App name")
|
||||
region: str = Field(description="Azure region")
|
||||
url: str = Field(description="Deployment URL")
|
||||
status: str = Field(description="Deployment status (deploying, deployed, failed)")
|
||||
created_at: str = Field(description="ISO 8601 timestamp")
|
||||
error: str | None = Field(default=None, description="Error message if failed")
|
||||
@@ -0,0 +1,413 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Custom OpenAI-compatible event types for Agent Framework extensions.
|
||||
|
||||
These are custom event types that extend beyond the standard OpenAI Responses API
|
||||
to support Agent Framework specific features like workflows and traces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Custom Agent Framework OpenAI event types for structured data
|
||||
|
||||
|
||||
# Agent lifecycle events - simple and clear
|
||||
class AgentStartedEvent:
|
||||
"""Event emitted when an agent starts execution."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentCompletedEvent:
|
||||
"""Event emitted when an agent completes execution successfully."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentFailedEvent:
|
||||
"""Event emitted when an agent fails during execution."""
|
||||
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
class ExecutorActionItem(BaseModel):
|
||||
"""Custom item type for workflow executor actions.
|
||||
|
||||
This is a DevUI-specific extension to represent workflow executors as output items.
|
||||
Since OpenAI's ResponseOutputItemAddedEvent only accepts specific item types,
|
||||
and executor actions are not part of the standard, we need this custom type.
|
||||
"""
|
||||
|
||||
type: Literal["executor_action"] = "executor_action"
|
||||
id: str
|
||||
executor_id: str
|
||||
status: Literal["in_progress", "completed", "failed", "cancelled"] = "in_progress"
|
||||
metadata: dict[str, Any] | None = None
|
||||
result: Any | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CustomResponseOutputItemAddedEvent(BaseModel):
|
||||
"""Custom version of ResponseOutputItemAddedEvent that accepts any item type.
|
||||
|
||||
This allows us to emit executor action items while maintaining the same
|
||||
event structure as OpenAI's standard.
|
||||
"""
|
||||
|
||||
type: Literal["response.output_item.added"] = "response.output_item.added"
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class CustomResponseOutputItemDoneEvent(BaseModel):
|
||||
"""Custom version of ResponseOutputItemDoneEvent that accepts any item type.
|
||||
|
||||
This allows us to emit executor action items while maintaining the same
|
||||
event structure as OpenAI's standard.
|
||||
"""
|
||||
|
||||
type: Literal["response.output_item.done"] = "response.output_item.done"
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class ResponseWorkflowEventComplete(BaseModel):
|
||||
"""Complete workflow event data.
|
||||
|
||||
DevUI extension for workflow execution events (debugging/observability).
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern.
|
||||
|
||||
Workflow events are shown in the debug panel for monitoring execution flow,
|
||||
not in main chat. Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.workflow_event.completed"] = "response.workflow_event.completed"
|
||||
data: dict[str, Any] # Complete event data, not delta
|
||||
executor_id: str | None = None
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
|
||||
|
||||
class ResponseTraceEventComplete(BaseModel):
|
||||
"""Complete trace event data.
|
||||
|
||||
DevUI extension for non-displayable debugging/metadata events.
|
||||
Uses past-tense 'completed' to follow OpenAI's event naming pattern
|
||||
(e.g., response.completed, response.output_item.added).
|
||||
|
||||
Trace events are shown in the Traces debug panel, not in main chat.
|
||||
Use response.output_item.added for user-facing content.
|
||||
"""
|
||||
|
||||
type: Literal["response.trace.completed"] = "response.trace.completed"
|
||||
data: dict[str, Any] # Complete trace data, not delta
|
||||
span_id: str | None = None
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
|
||||
|
||||
class ResponseFunctionResultComplete(BaseModel):
|
||||
"""DevUI extension: Stream function execution results.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API doesn't stream function results (clients execute functions)
|
||||
- Agent Framework executes functions server-side, so we stream results for debugging visibility
|
||||
- ResponseFunctionToolCallOutputItem exists in OpenAI SDK but isn't in ResponseOutputItem union
|
||||
(it's for Conversations API input, not Responses API streaming output)
|
||||
|
||||
This event provides the same structure as OpenAI's function output items but wrapped
|
||||
in a custom event type since standard events don't support streaming function results.
|
||||
"""
|
||||
|
||||
type: Literal["response.function_result.complete"] = "response.function_result.complete"
|
||||
call_id: str
|
||||
output: str
|
||||
status: Literal["in_progress", "completed", "incomplete"]
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
timestamp: str | None = None # Optional timestamp for UI display
|
||||
|
||||
|
||||
class ResponseRequestInfoEvent(BaseModel):
|
||||
"""DevUI extension: Workflow requests human input.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API doesn't have a concept of workflow human-in-the-loop pausing
|
||||
- Agent Framework workflows can pause via RequestInfoExecutor to collect external information
|
||||
- Clients need to render forms and submit responses to continue workflow execution
|
||||
|
||||
When a workflow emits this event, it enters IDLE_WITH_PENDING_REQUESTS state.
|
||||
Client should render a form based on request_schema and submit responses via
|
||||
a new request with workflow_hil_response content type.
|
||||
"""
|
||||
|
||||
type: Literal["response.request_info.requested"] = "response.request_info.requested"
|
||||
request_id: str
|
||||
"""Unique identifier for correlating this request with the response."""
|
||||
|
||||
source_executor_id: str
|
||||
"""ID of the executor that is waiting for this response."""
|
||||
|
||||
request_type: str
|
||||
"""Fully qualified type name of the request (e.g., 'module.path:ClassName')."""
|
||||
|
||||
request_data: dict[str, Any]
|
||||
"""Current data from the RequestInfoMessage (may contain defaults/context)."""
|
||||
|
||||
request_schema: dict[str, Any]
|
||||
"""JSON schema describing the request data structure (what the workflow is asking about)."""
|
||||
|
||||
response_schema: dict[str, Any] | None = None
|
||||
"""JSON schema describing the expected response structure for form rendering (what user should provide)."""
|
||||
|
||||
item_id: str
|
||||
"""OpenAI item ID for correlation."""
|
||||
|
||||
output_index: int = 0
|
||||
"""Output index for OpenAI compatibility."""
|
||||
|
||||
sequence_number: int
|
||||
"""Sequence number for ordering events."""
|
||||
|
||||
timestamp: str
|
||||
"""ISO timestamp when the request was made."""
|
||||
|
||||
|
||||
# DevUI Output Content Types - for agent-generated media/data
|
||||
# These extend ResponseOutputItem to support rich content outputs that OpenAI's API doesn't natively support
|
||||
|
||||
|
||||
class ResponseOutputImage(BaseModel):
|
||||
"""DevUI extension: Agent-generated image output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- ImageGenerationCall exists but is for tool calls (generating images), not returning existing images
|
||||
- Agent Framework agents can return images via DataContent/UriContent that need proper display
|
||||
|
||||
This type allows images to be displayed inline in chat rather than hidden in trace logs.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the image output."""
|
||||
|
||||
image_url: str
|
||||
"""The URL or data URI of the image (e.g., data:image/png;base64,...)"""
|
||||
|
||||
type: Literal["output_image"] = "output_image"
|
||||
"""The type of the output. Always `output_image`."""
|
||||
|
||||
alt_text: str | None = None
|
||||
"""Optional alt text for accessibility."""
|
||||
|
||||
mime_type: str = "image/png"
|
||||
"""The MIME type of the image (e.g., image/png, image/jpeg)."""
|
||||
|
||||
|
||||
class ResponseOutputFile(BaseModel):
|
||||
"""DevUI extension: Agent-generated file output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return files via DataContent/UriContent that need proper display
|
||||
- Supports PDFs, audio files, and other media types
|
||||
|
||||
This type allows files to be displayed inline in chat with appropriate renderers.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the file output."""
|
||||
|
||||
filename: str
|
||||
"""The filename (used to determine rendering and download)."""
|
||||
|
||||
type: Literal["output_file"] = "output_file"
|
||||
"""The type of the output. Always `output_file`."""
|
||||
|
||||
file_url: str | None = None
|
||||
"""Optional URL to the file."""
|
||||
|
||||
file_data: str | None = None
|
||||
"""Optional base64-encoded file data."""
|
||||
|
||||
mime_type: str = "application/octet-stream"
|
||||
"""The MIME type of the file (e.g., application/pdf, audio/mp3)."""
|
||||
|
||||
|
||||
class ResponseOutputData(BaseModel):
|
||||
"""DevUI extension: Agent-generated generic data output.
|
||||
|
||||
This is a DevUI extension because:
|
||||
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
|
||||
- Agent Framework agents can return arbitrary structured data that needs display
|
||||
- Useful for debugging and displaying non-text content
|
||||
|
||||
This type allows generic data to be displayed inline in chat.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""The unique ID of the data output."""
|
||||
|
||||
data: str
|
||||
"""The data payload (string representation)."""
|
||||
|
||||
type: Literal["output_data"] = "output_data"
|
||||
"""The type of the output. Always `output_data`."""
|
||||
|
||||
mime_type: str
|
||||
"""The MIME type of the data."""
|
||||
|
||||
description: str | None = None
|
||||
"""Optional description of the data."""
|
||||
|
||||
|
||||
# Agent Framework extension fields
|
||||
class AgentFrameworkExtraBody(BaseModel):
|
||||
"""Agent Framework specific routing fields for OpenAI requests."""
|
||||
|
||||
entity_id: str
|
||||
# input_data removed - now using standard input field for all data
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
# Agent Framework Request Model - Extending real OpenAI types
|
||||
class AgentFrameworkRequest(BaseModel):
|
||||
"""OpenAI ResponseCreateParams with Agent Framework routing.
|
||||
|
||||
This properly extends the real OpenAI API request format.
|
||||
- Uses 'model' field as entity_id (agent/workflow name)
|
||||
- Uses 'conversation' field for conversation context (OpenAI standard)
|
||||
"""
|
||||
|
||||
# All OpenAI fields from ResponseCreateParams
|
||||
model: str | None = None
|
||||
input: str | list[Any] | dict[str, Any] # ResponseInputParam + dict for workflow structured input
|
||||
stream: bool | None = False
|
||||
|
||||
# OpenAI conversation parameter (standard!)
|
||||
conversation: str | dict[str, Any] | None = None # Union[str, {"id": str}]
|
||||
|
||||
# Common OpenAI optional fields
|
||||
instructions: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
temperature: float | None = None
|
||||
max_output_tokens: int | None = None
|
||||
top_p: float | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
# Reasoning parameters (for o-series models)
|
||||
reasoning: dict[str, Any] | None = None # {"effort": "low" | "medium" | "high" | "minimal"}
|
||||
|
||||
# Optional extra_body for advanced use cases
|
||||
extra_body: dict[str, Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
def get_entity_id(self) -> str | None:
|
||||
"""Get entity_id from metadata.entity_id.
|
||||
|
||||
In DevUI, entity_id is specified in metadata for routing.
|
||||
"""
|
||||
if self.metadata:
|
||||
return self.metadata.get("entity_id")
|
||||
return None
|
||||
|
||||
def _get_conversation_id(self) -> str | None:
|
||||
"""Extract conversation_id from conversation parameter.
|
||||
|
||||
Supports both string and object forms:
|
||||
- conversation: "conv_123"
|
||||
- conversation: {"id": "conv_123"}
|
||||
"""
|
||||
if isinstance(self.conversation, str):
|
||||
return self.conversation
|
||||
if isinstance(self.conversation, dict):
|
||||
return self.conversation.get("id")
|
||||
return None
|
||||
|
||||
def to_openai_params(self) -> dict[str, Any]:
|
||||
"""Convert to dict for OpenAI client compatibility."""
|
||||
return self.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
# Error handling
|
||||
class ResponseTraceEvent(BaseModel):
|
||||
"""Trace event for execution tracing."""
|
||||
|
||||
type: Literal["trace_event"] = "trace_event"
|
||||
data: dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
class OpenAIError(BaseModel):
|
||||
"""OpenAI standard error response model."""
|
||||
|
||||
error: dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> OpenAIError:
|
||||
"""Create a standard OpenAI error response."""
|
||||
error_data = {"message": message, "type": type, "code": code}
|
||||
return cls(error=error_data)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return the error payload as a plain mapping."""
|
||||
return {"error": dict(self.error)}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Return the error payload serialized to JSON."""
|
||||
return self.model_dump_json()
|
||||
|
||||
|
||||
class MetaResponse(BaseModel):
|
||||
"""Server metadata response for /meta endpoint.
|
||||
|
||||
Provides information about the DevUI server configuration and capabilities.
|
||||
"""
|
||||
|
||||
ui_mode: Literal["developer", "user"] = "developer"
|
||||
"""UI interface mode - 'developer' shows debug tools, 'user' shows simplified interface."""
|
||||
|
||||
version: str
|
||||
"""DevUI version string."""
|
||||
|
||||
framework: str = "agent_framework"
|
||||
"""Backend framework identifier."""
|
||||
|
||||
runtime: Literal["python", "dotnet"] = "python"
|
||||
"""Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability."""
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
"""Server capabilities (e.g., instrumentation, openai_proxy)."""
|
||||
|
||||
auth_required: bool = False
|
||||
"""Whether the server requires Bearer token authentication."""
|
||||
|
||||
|
||||
# Export all custom types
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"MetaResponse",
|
||||
"OpenAIError",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseOutputData",
|
||||
"ResponseOutputFile",
|
||||
"ResponseOutputImage",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseWorkflowEventComplete",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_iii_510_1294)">
|
||||
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="8.39647" dy="8.39647"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="20.9912"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-26.2432" dy="-26.2432"/>
|
||||
<feGaussianBlur stdDeviation="50"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
|
||||
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D59FFF"/>
|
||||
<stop offset="1" stop-color="#8562C5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="./assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user