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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,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>