Python: Add DevUI to AgentFramework (#781)

* add initial backend service code for devui

* add tests

* add frontendcode

* ui updates

* update readme

* ui updates and tweaks

* update ui bundle

* improve ui, add react flow base

* add react flow ui, fix background

* update ui, fix introspection bug

* update readme

* update ui build

* add support for multimodal input - both backend and frontend

* update ui build

* refactor as main framework package

* backend and tests refactor

* ui build update

* ui build update and refactor

* update pyproject.toml, update uv.lock

* update ui build

* ui update to fit oai responses types

* add backend updat and readme update

* mypy and other fixes

* add intial dev guide

* update ui and fix workflow bug

* update ui build, add thread support

* type fixes

* update workflow view

* update uv.lock

* fix workflow iport errors

* lint and other fixes

* mypy fixes

* minor update

* update ui build

* refactor to use oai dependencies directly, update examples to samples, improve typing

* readme update

* update ui and ui build

* fix workflow pyright error

* update ui, fix issues with run workflow placement, miniamp menu, etc

* make samples integrate serve

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-22 16:30:08 -07:00
committed by GitHub
Unverified
parent adb6dcd2af
commit 1ef24d3e91
98 changed files with 18045 additions and 4 deletions
@@ -0,0 +1,131 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework DevUI - Debug interface with OpenAI compatible API server."""
import importlib.metadata
import logging
import webbrowser
from typing import Any
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo
logger = logging.getLogger(__name__)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
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,
) -> 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
"""
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")
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
)
# 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._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",
"DevServer",
"DiscoveryResponse",
"EntityInfo",
"OpenAIError",
"OpenAIResponse",
"ResponseStreamEvent",
"main",
"serve",
]
@@ -0,0 +1,135 @@
# 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
""",
)
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("--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) -> 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
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
# Print startup info
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload)
# 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
)
except KeyboardInterrupt:
print("\n👋 Shutting 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,550 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework entity discovery implementation."""
import importlib
import importlib.util
import logging
import sys
import uuid
from pathlib import Path
from typing import Any
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] = {}
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()
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)
def list_entities(self) -> list[EntityInfo]:
"""List all discovered entities.
Returns:
List of all entity information
"""
return list(self._entities.values())
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
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) -> EntityInfo:
"""Create EntityInfo from Agent Framework entity object.
Args:
entity_object: Agent Framework entity object
entity_type: Optional entity type override
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 ID with entity type prefix since no directory name available
entity_id_raw = getattr(entity_object, "id", None)
if entity_id_raw:
# Truncate UUID to first 8 characters for readability
short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
name = f"{entity_type.title()} {short_id}"
else:
# Fallback to class name with entity type
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)
# Extract tools/executors using Agent Framework specific logic
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
name=name,
description=description,
type=entity_type,
framework="agent_framework",
tools=[str(tool) for tool in (tools_list or [])],
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,
metadata={
"source": "agent_framework_object",
"class_name": entity_object.__class__.__name__
if hasattr(entity_object, "__class__")
else str(type(entity_object)),
"has_run_stream": hasattr(entity_object, "run_stream"),
},
)
async def _scan_entities_directory(self, entities_dir: Path) -> None:
"""Scan the entities directory for Agent Framework entities.
Args:
entities_dir: Directory to scan for entities
"""
if not entities_dir.exists():
logger.warning(f"Entities directory not found: {entities_dir}")
return
logger.info(f"Scanning {entities_dir} for Agent Framework entities...")
# 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
for item in entities_dir.iterdir():
if item.name.startswith(".") or item.name == "__pycache__":
continue
if item.is_dir():
# Directory-based entity
await self._discover_entities_in_directory(item)
elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
# Single file entity
await self._discover_entities_in_file(item)
async def _discover_entities_in_directory(self, dir_path: Path) -> None:
"""Discover entities in a directory using module import.
Args:
dir_path: Directory containing entity
"""
entity_id = dir_path.name
logger.debug(f"Scanning directory: {entity_id}")
try:
# Load environment variables for this entity first
self._load_env_for_entity(dir_path)
# Try different import patterns
import_patterns = [
entity_id, # Direct module import
f"{entity_id}.agent", # agent.py submodule
f"{entity_id}.workflow", # workflow.py submodule
]
for pattern in import_patterns:
module = self._load_module_from_pattern(pattern)
if module:
entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path))
if entities_found:
logger.debug(f"Found {len(entities_found)} entities in {pattern}")
break
except Exception as e:
logger.warning(f"Error scanning directory {entity_id}: {e}")
async def _discover_entities_in_file(self, file_path: Path) -> None:
"""Discover entities in a single Python file.
Args:
file_path: Python file to scan
"""
try:
# Load environment variables for this entity's directory first
self._load_env_for_entity(file_path.parent)
# Create module name from file path
base_name = file_path.stem
# Load the module directly from file
module = self._load_module_from_file(file_path, base_name)
if module:
entities_found = await self._find_entities_in_module(module, base_name, str(file_path))
if entities_found:
logger.debug(f"Found {len(entities_found)} entities in {file_path.name}")
except Exception as e:
logger.warning(f"Error scanning file {file_path}: {e}")
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) -> Any | None:
"""Load module using import pattern.
Args:
pattern: Import pattern to try
Returns:
Loaded module or None if failed
"""
try:
# Check if module exists first
spec = importlib.util.find_spec(pattern)
if spec is None:
return None
module = importlib.import_module(pattern)
logger.debug(f"Successfully imported {pattern}")
return module
except ModuleNotFoundError:
logger.debug(f"Import pattern {pattern} not found")
return None
except Exception as e:
logger.warning(f"Error importing {pattern}: {e}")
return None
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_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]:
"""Find agent and workflow entities in a loaded module.
Args:
module: Loaded Python module
base_id: Base identifier for entities
module_path: Path to module for metadata
Returns:
List of entity IDs that were found and registered
"""
entities_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):
entity_id = f"{obj_type}_{base_id}"
await self._register_entity_from_object(entity_id, obj, obj_type, module_path)
entities_found.append(entity_id)
return entities_found
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 AgentProtocol for proper type checking
try:
from agent_framework import AgentProtocol
if isinstance(obj, AgentProtocol):
return True
except ImportError:
pass
# Fallback to duck typing for agent protocol
if hasattr(obj, "run_stream") 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_stream method and executors
return hasattr(obj, "run_stream") and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
async def _register_entity_from_object(self, entity_id: str, obj: Any, obj_type: str, module_path: str) -> None:
"""Register an entity from a live object.
Args:
entity_id: Unique entity identifier
obj: Entity object
obj_type: Type of entity ("agent" or "workflow")
module_path: Path to module for metadata
"""
try:
# Extract metadata from the live object with improved fallback naming
name = getattr(obj, "name", None)
if not name:
# For directory-based entities, prefer directory name over UUID
# entity_id format: "workflow_fanout_workflow" or "agent_weather_agent"
if entity_id and "_" in entity_id:
# Directory-based: use formatted directory name (remove type prefix)
directory_name = entity_id.split("_", 1)[1] if "_" in entity_id else entity_id
name = directory_name.replace("_", " ").title()
else:
# In-memory: use ID with entity type prefix
entity_id_raw = getattr(obj, "id", None)
if entity_id_raw:
# Truncate UUID to first 8 characters for readability
short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
name = f"{obj_type.title()} {short_id}"
else:
# Final fallback to class name
name = f"{obj_type.title()} {obj.__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]
entity_info = EntityInfo(
id=entity_id,
type=obj_type,
name=name,
framework="agent_framework",
description=description,
tools=tools_union,
metadata={
"module_path": module_path,
"entity_type": obj_type,
"source": "module_import",
"has_run_stream": hasattr(obj, "run_stream"),
"class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
},
)
# Register the entity
self.register_entity(entity_id, entity_info, obj)
except Exception as e:
logger.error(f"Error registering entity {entity_id}: {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 = []
try:
if obj_type == "agent":
# For agents, check chat_options.tools first
chat_options = getattr(obj, "chat_options", None)
if chat_options and hasattr(chat_options, "tools"):
for tool in chat_options.tools:
if hasattr(tool, "__name__"):
tools.append(tool.__name__)
elif hasattr(tool, "name"):
tools.append(tool.name)
else:
tools.append(str(tool))
else:
# Fallback to direct tools attribute
agent_tools = getattr(obj, "tools", None)
if agent_tools:
for tool in agent_tools:
if hasattr(tool, "__name__"):
tools.append(tool.__name__)
elif hasattr(tool, "name"):
tools.append(tool.name)
else:
tools.append(str(tool))
elif obj_type == "workflow":
# For workflows, extract executor names
if hasattr(obj, "get_executors_list"):
executor_objects = obj.get_executors_list()
tools = [getattr(ex, "id", str(ex)) for ex in executor_objects]
elif hasattr(obj, "executors"):
executors = obj.executors
if isinstance(executors, list):
tools = [getattr(ex, "id", str(ex)) for ex in executors]
elif isinstance(executors, dict):
tools = list(executors.keys())
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) -> str:
"""Generate entity ID with priority: name -> id -> class_name -> uuid.
Args:
entity: Entity object
entity_type: Type of entity (agent, workflow, etc.)
Returns:
Generated entity ID
"""
import re
# Priority 1: entity.name
if hasattr(entity, "name") and entity.name:
name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
return f"{entity_type}_{name}"
# Priority 2: entity.id
if hasattr(entity, "id") and entity.id:
entity_id = str(entity.id).lower().replace(" ", "-").replace("_", "-")
return f"{entity_type}_{entity_id}"
# Priority 3: class name
if hasattr(entity, "__class__"):
class_name = entity.__class__.__name__
# Convert CamelCase to kebab-case
class_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
return f"{entity_type}_{class_name}"
# Priority 4: fallback to uuid
return f"{entity_type}_{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,745 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework executor implementation."""
import json
import logging
import os
import uuid
from collections.abc import AsyncGenerator
from typing import Any
from agent_framework import AgentThread
from ._discovery import EntityDiscovery
from ._mapper import MessageMapper
from ._tracing import capture_traces
from .models import AgentFrameworkRequest, OpenAIResponse
from .models._discovery_models import EntityInfo
logger = logging.getLogger(__name__)
class EntityNotFoundError(Exception):
"""Raised when an entity is not found."""
pass
class AgentFrameworkExecutor:
"""Executor for Agent Framework entities - agents and workflows."""
def __init__(self, entity_discovery: EntityDiscovery, message_mapper: MessageMapper):
"""Initialize Agent Framework executor.
Args:
entity_discovery: Entity discovery instance
message_mapper: Message mapper instance
"""
self.entity_discovery = entity_discovery
self.message_mapper = message_mapper
self._setup_tracing_provider()
self._setup_agent_framework_tracing()
# Minimal thread storage - no metadata needed
self.thread_storage: dict[str, AgentThread] = {}
self.agent_threads: dict[str, list[str]] = {} # agent_id -> thread_ids
def _setup_tracing_provider(self) -> None:
"""Set up our own TracerProvider so we can add processors."""
try:
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
# Only set up if no provider exists yet
if not hasattr(trace, "_TRACER_PROVIDER") or trace._TRACER_PROVIDER is None:
resource = Resource.create({
"service.name": "agent-framework-server",
"service.version": "1.0.0",
})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
logger.info("Set up TracerProvider for server tracing")
else:
logger.debug("TracerProvider already exists")
except ImportError:
logger.debug("OpenTelemetry not available")
except Exception as e:
logger.warning(f"Failed to setup TracerProvider: {e}")
def _setup_agent_framework_tracing(self) -> None:
"""Set up Agent Framework's built-in tracing."""
# Configure Agent Framework tracing only if OTLP endpoint is configured
otlp_endpoint = os.environ.get("AGENT_FRAMEWORK_OTLP_ENDPOINT")
if otlp_endpoint:
try:
from agent_framework.telemetry import setup_telemetry
setup_telemetry(enable_otel=True, enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
logger.info(f"Enabled Agent Framework telemetry with endpoint: {otlp_endpoint}")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework tracing: {e}")
else:
logger.debug("No OTLP endpoint configured, skipping telemetry setup")
# Thread Management Methods
def create_thread(self, agent_id: str) -> str:
"""Create new thread for agent."""
thread_id = f"thread_{uuid.uuid4().hex[:8]}"
thread = AgentThread()
self.thread_storage[thread_id] = thread
if agent_id not in self.agent_threads:
self.agent_threads[agent_id] = []
self.agent_threads[agent_id].append(thread_id)
return thread_id
def get_thread(self, thread_id: str) -> AgentThread | None:
"""Get AgentThread by ID."""
return self.thread_storage.get(thread_id)
def list_threads_for_agent(self, agent_id: str) -> list[str]:
"""List thread IDs for agent."""
return self.agent_threads.get(agent_id, [])
def get_agent_for_thread(self, thread_id: str) -> str | None:
"""Find which agent owns this thread."""
for agent_id, thread_ids in self.agent_threads.items():
if thread_id in thread_ids:
return agent_id
return None
def delete_thread(self, thread_id: str) -> bool:
"""Delete thread."""
if thread_id not in self.thread_storage:
return False
# Remove from agent mapping
for _agent_id, thread_ids in self.agent_threads.items():
if thread_id in thread_ids:
thread_ids.remove(thread_id)
break
del self.thread_storage[thread_id]
return True
async def get_thread_messages(self, thread_id: str) -> list[dict[str, Any]]:
"""Get messages from a thread's message store, filtering for UI display."""
thread = self.get_thread(thread_id)
if not thread or not thread.message_store:
return []
try:
# Get AgentFramework ChatMessage objects from thread
af_messages = await thread.message_store.list_messages()
ui_messages = []
for i, af_msg in enumerate(af_messages):
# Extract role value (handle enum)
role = af_msg.role.value if hasattr(af_msg.role, "value") else str(af_msg.role)
# Skip tool/function messages - only show user and assistant text
if role not in ["user", "assistant"]:
continue
# Extract user-facing text content only
text_content = self._extract_display_text(af_msg.contents)
# Skip messages with no displayable text
if not text_content:
continue
ui_message = {
"id": af_msg.message_id or f"restored-{i}",
"role": role,
"contents": [{"type": "text", "text": text_content}],
"timestamp": __import__("datetime").datetime.now().isoformat(),
"author_name": af_msg.author_name,
"message_id": af_msg.message_id,
}
ui_messages.append(ui_message)
logger.info(f"Restored {len(ui_messages)} display messages for thread {thread_id}")
return ui_messages
except Exception as e:
logger.error(f"Error getting thread messages: {e}")
import traceback
logger.error(traceback.format_exc())
return []
def _extract_display_text(self, contents: list[Any]) -> str:
"""Extract user-facing text from message contents, filtering out internal mechanics."""
text_parts = []
for content in contents:
content_type = getattr(content, "type", None)
# Only include text content for display
if content_type == "text":
text = getattr(content, "text", "")
# Handle double-encoded JSON from user messages
if text.startswith('{"role":'):
try:
import json
parsed = json.loads(text)
if parsed.get("contents"):
for sub_content in parsed["contents"]:
if sub_content.get("type") == "text":
text_parts.append(sub_content.get("text", ""))
except Exception:
text_parts.append(text) # Fallback to raw text
else:
text_parts.append(text)
# Skip function_call, function_result, and other internal content types
return " ".join(text_parts).strip()
async def serialize_thread(self, thread_id: str) -> dict[str, Any] | None:
"""Serialize thread state for persistence."""
thread = self.get_thread(thread_id)
if not thread:
return None
try:
# Use AgentThread's built-in serialization
serialized_state = await thread.serialize()
# Add our metadata
agent_id = self.get_agent_for_thread(thread_id)
serialized_state["metadata"] = {"agent_id": agent_id, "thread_id": thread_id}
return serialized_state
except Exception as e:
logger.error(f"Error serializing thread {thread_id}: {e}")
return None
async def deserialize_thread(self, thread_id: str, agent_id: str, serialized_state: dict[str, Any]) -> bool:
"""Deserialize thread state from persistence."""
try:
# Create new thread
thread = AgentThread()
# Use AgentThread's built-in deserialization
from agent_framework._threads import deserialize_thread_state
await deserialize_thread_state(thread, serialized_state)
# Store the restored thread
self.thread_storage[thread_id] = thread
if agent_id not in self.agent_threads:
self.agent_threads[agent_id] = []
self.agent_threads[agent_id].append(thread_id)
return True
except Exception as e:
logger.error(f"Error deserializing thread {thread_id}: {e}")
return False
async def discover_entities(self) -> list[EntityInfo]:
"""Discover all available entities.
Returns:
List of discovered entities
"""
return await self.entity_discovery.discover_entities()
def get_entity_info(self, entity_id: str) -> EntityInfo:
"""Get entity information.
Args:
entity_id: Entity identifier
Returns:
Entity information
Raises:
EntityNotFoundError: If entity is not found
"""
entity_info = self.entity_discovery.get_entity_info(entity_id)
if entity_info is None:
raise EntityNotFoundError(f"Entity '{entity_id}' not found")
return entity_info
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]:
"""Execute request and stream results in OpenAI format.
Args:
request: Request to execute
Yields:
OpenAI response stream events
"""
try:
entity_id = request.get_entity_id()
if not entity_id:
logger.error("No entity_id specified in request")
return
# Validate entity exists
if not self.entity_discovery.get_entity_info(entity_id):
logger.error(f"Entity '{entity_id}' not found")
return
# Execute entity and convert events
async for raw_event in self.execute_entity(entity_id, request):
openai_events = await self.message_mapper.convert_event(raw_event, request)
for event in openai_events:
yield event
except Exception as e:
logger.exception(f"Error in streaming execution: {e}")
# Could yield error event here
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
"""Execute request synchronously and return complete response.
Args:
request: Request to execute
Returns:
Final aggregated OpenAI response
"""
# Collect all streaming events
events = [event async for event in self.execute_streaming(request)]
# Aggregate into final response
return await self.message_mapper.aggregate_to_response(events, request)
async def execute_entity(self, entity_id: str, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]:
"""Execute the entity and yield raw Agent Framework events plus trace events.
Args:
entity_id: ID of entity to execute
request: Request to execute
Yields:
Raw Agent Framework events and trace events
"""
try:
# Get entity info and object
entity_info = self.get_entity_info(entity_id)
entity_obj = self.entity_discovery.get_entity_object(entity_id)
if not entity_obj:
raise EntityNotFoundError(f"Entity object for '{entity_id}' not found")
logger.info(f"Executing {entity_info.type}: {entity_id}")
# Extract session_id from request for trace context
session_id = getattr(request.extra_body, "session_id", None) if request.extra_body else None
# Use simplified trace capture
with capture_traces(session_id=session_id, entity_id=entity_id) as trace_collector:
if entity_info.type == "agent":
async for event in self._execute_agent(entity_obj, request, trace_collector):
yield event
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
yield event
else:
raise ValueError(f"Unsupported entity type: {entity_info.type}")
# Yield any remaining trace events after execution completes
for trace_event in trace_collector.get_pending_events():
yield trace_event
except Exception as e:
logger.exception(f"Error executing entity {entity_id}: {e}")
# Yield error event
yield {"type": "error", "message": str(e), "entity_id": entity_id}
async def _execute_agent(
self, agent: Any, request: AgentFrameworkRequest, trace_collector: Any
) -> AsyncGenerator[Any, None]:
"""Execute Agent Framework agent with trace collection and optional thread support.
Args:
agent: Agent object to execute
request: Request to execute
trace_collector: Trace collector to get events from
Yields:
Agent update events and trace events
"""
try:
# Convert input to proper ChatMessage or string
user_message = self._convert_input_to_chat_message(request.input)
# Get thread if provided in extra_body
thread = None
if request.extra_body and hasattr(request.extra_body, "thread_id") and request.extra_body.thread_id:
thread_id = request.extra_body.thread_id
thread = self.get_thread(thread_id)
if thread:
logger.debug(f"Using existing thread: {thread_id}")
else:
logger.warning(f"Thread {thread_id} not found, proceeding without thread")
# Debug logging - handle both string and ChatMessage
if isinstance(user_message, str):
logger.debug(f"Executing agent with text input: {user_message[:100]}...")
else:
logger.debug(f"Executing agent with multimodal ChatMessage: {type(user_message)}")
# Use Agent Framework's native streaming with optional thread
if thread:
async for update in agent.run_stream(user_message, thread=thread):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Then yield the execution update
yield update
else:
async for update in agent.run_stream(user_message):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Then yield the execution update
yield update
except Exception as e:
logger.error(f"Error in agent execution: {e}")
yield {"type": "error", "message": f"Agent execution error: {e!s}"}
async def _execute_workflow(
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
) -> AsyncGenerator[Any, None]:
"""Execute Agent Framework workflow with trace collection.
Args:
workflow: Workflow object to execute
request: Request to execute
trace_collector: Trace collector to get events from
Yields:
Workflow events and trace events
"""
try:
# Get input data - prefer structured data from extra_body
input_data: str | list[Any] | dict[str, Any]
if request.extra_body and hasattr(request.extra_body, "input_data") and request.extra_body.input_data:
input_data = request.extra_body.input_data
logger.debug(f"Using structured input_data from extra_body: {type(input_data)}")
else:
input_data = request.input
logger.debug(f"Using input field as fallback: {type(input_data)}")
# Parse input based on workflow's expected input type
parsed_input = await self._parse_workflow_input(workflow, input_data)
logger.debug(f"Executing workflow with parsed input type: {type(parsed_input)}")
# Use Agent Framework workflow's native streaming
async for event in workflow.run_stream(parsed_input):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Then yield the workflow event
yield event
except Exception as e:
logger.error(f"Error in workflow execution: {e}")
yield {"type": "error", "message": f"Workflow execution error: {e!s}"}
def _convert_input_to_chat_message(self, input_data: Any) -> Any:
"""Convert OpenAI Responses API input to Agent Framework ChatMessage or string.
Args:
input_data: OpenAI ResponseInputParam (List[ResponseInputItemParam])
Returns:
ChatMessage for multimodal content, or string for simple text
"""
# Import Agent Framework types
try:
from agent_framework import ChatMessage, DataContent, Role, TextContent
except ImportError:
# Fallback to string extraction if Agent Framework not available
return self._extract_user_message_fallback(input_data)
# Handle simple string input (backward compatibility)
if isinstance(input_data, str):
return input_data
# Handle OpenAI ResponseInputParam (List[ResponseInputItemParam])
if isinstance(input_data, list):
return self._convert_openai_input_to_chat_message(input_data, ChatMessage, TextContent, DataContent, Role)
# Fallback for other formats
return self._extract_user_message_fallback(input_data)
def _convert_openai_input_to_chat_message(
self, input_items: list[Any], ChatMessage: Any, TextContent: Any, DataContent: Any, Role: Any
) -> Any:
"""Convert OpenAI ResponseInputParam to Agent Framework ChatMessage.
Args:
input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects)
ChatMessage: ChatMessage class for creating chat messages
TextContent: TextContent class for text content
DataContent: DataContent class for data/media content
Role: Role enum for message roles
Returns:
ChatMessage with converted content
"""
contents = []
# Process each input item
for item in input_items:
# Handle dict format (from JSON)
if isinstance(item, dict):
item_type = item.get("type")
if item_type == "message":
# Extract content from OpenAI message
message_content = item.get("content", [])
# Handle both string content and list content
if isinstance(message_content, str):
contents.append(TextContent(text=message_content))
elif isinstance(message_content, list):
for content_item in message_content:
# Handle dict content items
if isinstance(content_item, dict):
content_type = content_item.get("type")
if content_type == "input_text":
text = content_item.get("text", "")
contents.append(TextContent(text=text))
elif content_type == "input_image":
image_url = content_item.get("image_url", "")
if image_url:
# Extract media type from data URI if possible
# Parse media type from data URL, fallback to image/png
if image_url.startswith("data:"):
try:
# Extract media type from data:image/jpeg;base64,... format
media_type = image_url.split(";")[0].split(":")[1]
except (IndexError, AttributeError):
logger.warning(
f"Failed to parse media type from data URL: {image_url[:30]}..."
)
media_type = "image/png"
else:
media_type = "image/png"
contents.append(DataContent(uri=image_url, media_type=media_type))
elif content_type == "input_file":
# Handle file input
file_data = content_item.get("file_data")
file_url = content_item.get("file_url")
filename = content_item.get("filename", "")
# Determine media type from filename
media_type = "application/octet-stream" # default
if filename:
if filename.lower().endswith(".pdf"):
media_type = "application/pdf"
elif filename.lower().endswith((".png", ".jpg", ".jpeg", ".gif")):
media_type = f"image/{filename.split('.')[-1].lower()}"
# Use file_data or file_url
if file_data:
# Assume file_data is base64, create data URI
data_uri = f"data:{media_type};base64,{file_data}"
contents.append(DataContent(uri=data_uri, media_type=media_type))
elif file_url:
contents.append(DataContent(uri=file_url, media_type=media_type))
# Handle other OpenAI input item types as needed
# (tool calls, function results, etc.)
# If no contents found, create a simple text message
if not contents:
contents.append(TextContent(text=""))
# Create ChatMessage with user role
return ChatMessage(role=Role.USER, contents=contents)
def _extract_user_message_fallback(self, input_data: Any) -> str:
"""Fallback method to extract user message as string.
Args:
input_data: Input data in various formats
Returns:
Extracted user message string
"""
if isinstance(input_data, str):
return input_data
if isinstance(input_data, dict):
# Try common field names
for field in ["message", "text", "input", "content", "query"]:
if field in input_data:
return str(input_data[field])
# Fallback to JSON string
return json.dumps(input_data)
return str(input_data)
async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any:
"""Parse input based on workflow's expected input type.
Args:
workflow: Workflow object
raw_input: Raw input data
Returns:
Parsed input appropriate for the workflow
"""
try:
# Handle structured input
if isinstance(raw_input, dict):
return self._parse_structured_workflow_input(workflow, raw_input)
return self._parse_raw_workflow_input(workflow, str(raw_input))
except Exception as e:
logger.warning(f"Error parsing workflow input: {e}")
return raw_input
def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any:
"""Parse structured input data for workflow execution.
Args:
workflow: Workflow object
input_data: Structured input data
Returns:
Parsed input for workflow
"""
try:
# Get the start executor and its input type
start_executor = workflow.get_start_executor()
if not start_executor or not hasattr(start_executor, "_handlers"):
logger.debug("Cannot determine input type for workflow - using raw dict")
return input_data
message_types = list(start_executor._handlers.keys())
if not message_types:
logger.debug("No message types found for start executor - using raw dict")
return input_data
# Get the first (primary) input type
input_type = message_types[0]
# If input type is dict, return as-is
if input_type is dict:
return input_data
# Handle primitive types
if input_type in (str, int, float, bool):
try:
if isinstance(input_data, input_type):
return input_data
if "input" in input_data:
return input_type(input_data["input"])
if len(input_data) == 1:
value = next(iter(input_data.values()))
return input_type(value)
return input_data
except (ValueError, TypeError) as e:
logger.warning(f"Failed to convert input to {input_type}: {e}")
return input_data
# If it's a Pydantic model, validate and create instance
if hasattr(input_type, "model_validate"):
try:
return input_type.model_validate(input_data)
except Exception as e:
logger.warning(f"Failed to validate input as {input_type}: {e}")
return input_data
# If it's a dataclass or other type with annotations
elif hasattr(input_type, "__annotations__"):
try:
return input_type(**input_data)
except Exception as e:
logger.warning(f"Failed to create {input_type} from input data: {e}")
return input_data
except Exception as e:
logger.warning(f"Error parsing structured workflow input: {e}")
return input_data
def _parse_raw_workflow_input(self, workflow: Any, raw_input: str) -> Any:
"""Parse raw input string based on workflow's expected input type.
Args:
workflow: Workflow object
raw_input: Raw input string
Returns:
Parsed input for workflow
"""
try:
# Get the start executor and its input type
start_executor = workflow.get_start_executor()
if not start_executor or not hasattr(start_executor, "_handlers"):
logger.debug("Cannot determine input type for workflow - using raw string")
return raw_input
message_types = list(start_executor._handlers.keys())
if not message_types:
logger.debug("No message types found for start executor - using raw string")
return raw_input
# Get the first (primary) input type
input_type = message_types[0]
# If input type is str, return as-is
if input_type is str:
return raw_input
# If it's a Pydantic model, try to parse JSON
if hasattr(input_type, "model_validate_json"):
try:
# First try to parse as JSON
if raw_input.strip().startswith("{"):
return input_type.model_validate_json(raw_input)
# Try common field names
common_fields = ["message", "text", "input", "data", "content"]
for field in common_fields:
try:
return input_type(**{field: raw_input})
except Exception as e:
logger.debug(f"Failed to parse input using field '{field}': {e}")
continue
# Last resort: try default constructor
return input_type()
except Exception as e:
logger.debug(f"Failed to parse input as {input_type}: {e}")
# If it's a dataclass, try JSON parsing
elif hasattr(input_type, "__annotations__"):
try:
if raw_input.strip().startswith("{"):
parsed = json.loads(raw_input)
return input_type(**parsed)
except Exception as e:
logger.debug(f"Failed to parse input as {input_type}: {e}")
except Exception as e:
logger.debug(f"Error determining workflow input type: {e}")
# Fallback: return raw string
return raw_input
@@ -0,0 +1,527 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework message mapper implementation."""
import json
import logging
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Union
from .models import (
AgentFrameworkRequest,
InputTokensDetails,
OpenAIResponse,
OutputTokensDetails,
ResponseErrorEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionResultComplete,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningTextDeltaEvent,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ResponseTraceEventComplete,
ResponseUsage,
ResponseUsageEventComplete,
ResponseWorkflowEventComplete,
)
logger = logging.getLogger(__name__)
# Type alias for all possible event types
EventType = Union[
ResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseFunctionResultComplete,
ResponseTraceEventComplete,
ResponseUsageEventComplete,
]
class MessageMapper:
"""Maps Agent Framework messages/responses to OpenAI format."""
def __init__(self) -> None:
"""Initialize Agent Framework message mapper."""
self.sequence_counter = 0
self._conversion_contexts: dict[int, dict[str, Any]] = {}
# Register content type mappers for all 12 Agent Framework content types
self.content_mappers = {
"TextContent": self._map_text_content,
"TextReasoningContent": self._map_reasoning_content,
"FunctionCallContent": self._map_function_call_content,
"FunctionResultContent": self._map_function_result_content,
"ErrorContent": self._map_error_content,
"UsageContent": self._map_usage_content,
"DataContent": self._map_data_content,
"UriContent": self._map_uri_content,
"HostedFileContent": self._map_hosted_file_content,
"HostedVectorStoreContent": self._map_hosted_vector_store_content,
"FunctionApprovalRequestContent": self._map_approval_request_content,
"FunctionApprovalResponseContent": self._map_approval_response_content,
}
async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> Sequence[Any]:
"""Convert a single Agent Framework event to OpenAI events.
Args:
raw_event: Agent Framework event (AgentRunResponseUpdate, WorkflowEvent, etc.)
request: Original request for context
Returns:
List of OpenAI response stream events
"""
context = self._get_or_create_context(request)
# Handle error events
if isinstance(raw_event, dict) and raw_event.get("type") == "error":
return [await self._create_error_event(raw_event.get("message", "Unknown error"), context)]
# Handle ResponseTraceEvent objects from our trace collector
from .models import ResponseTraceEvent
if isinstance(raw_event, ResponseTraceEvent):
return [
ResponseTraceEventComplete(
type="response.trace.complete",
data=raw_event.data,
item_id=context["item_id"],
sequence_number=self._next_sequence(context),
)
]
# Import Agent Framework types for proper isinstance checks
try:
from agent_framework import AgentRunResponseUpdate, WorkflowEvent
# Handle agent updates (AgentRunResponseUpdate)
if isinstance(raw_event, AgentRunResponseUpdate):
return await self._convert_agent_update(raw_event, context)
# Handle workflow events (any class that inherits from WorkflowEvent)
if isinstance(raw_event, WorkflowEvent):
return await self._convert_workflow_event(raw_event, context)
except ImportError as e:
logger.warning(f"Could not import Agent Framework types: {e}")
# Fallback to attribute-based detection
if hasattr(raw_event, "contents"):
return await self._convert_agent_update(raw_event, context)
if hasattr(raw_event, "__class__") and "Event" in raw_event.__class__.__name__:
return await self._convert_workflow_event(raw_event, context)
# Unknown event type
return [await self._create_unknown_event(raw_event, context)]
async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrameworkRequest) -> OpenAIResponse:
"""Aggregate streaming events into final OpenAI response.
Args:
events: List of OpenAI stream events
request: Original request for context
Returns:
Final aggregated OpenAI response
"""
try:
# Extract text content from events
content_parts = []
for event in events:
# Extract delta text from ResponseTextDeltaEvent
if hasattr(event, "delta") and hasattr(event, "type") and event.type == "response.output_text.delta":
content_parts.append(event.delta)
# Combine content
full_content = "".join(content_parts)
# Create proper OpenAI Response
response_output_text = ResponseOutputText(type="output_text", text=full_content, annotations=[])
response_output_message = ResponseOutputMessage(
type="message",
role="assistant",
content=[response_output_text],
id=f"msg_{uuid.uuid4().hex[:8]}",
status="completed",
)
# Create usage object
input_token_count = len(str(request.input)) // 4 if request.input else 0
output_token_count = len(full_content) // 4
usage = ResponseUsage(
input_tokens=input_token_count,
output_tokens=output_token_count,
total_tokens=input_token_count + output_token_count,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
return OpenAIResponse(
id=f"resp_{uuid.uuid4().hex[:12]}",
object="response",
created_at=datetime.now().timestamp(),
model=request.model,
output=[response_output_message],
usage=usage,
parallel_tool_calls=False,
tool_choice="none",
tools=[],
)
except Exception as e:
logger.exception(f"Error aggregating response: {e}")
return await self._create_error_response(str(e), request)
def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]:
"""Get or create conversion context for this request.
Args:
request: Request to get context for
Returns:
Conversion context dictionary
"""
request_key = id(request)
if request_key not in self._conversion_contexts:
self._conversion_contexts[request_key] = {
"sequence_counter": 0,
"item_id": f"msg_{uuid.uuid4().hex[:8]}",
"content_index": 0,
"output_index": 0,
}
return self._conversion_contexts[request_key]
def _next_sequence(self, context: dict[str, Any]) -> int:
"""Get next sequence number for events.
Args:
context: Conversion context
Returns:
Next sequence number
"""
context["sequence_counter"] += 1
return int(context["sequence_counter"])
async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]:
"""Convert AgentRunResponseUpdate to OpenAI events using comprehensive content mapping.
Args:
update: Agent run response update
context: Conversion context
Returns:
List of OpenAI response stream events
"""
events: list[Any] = []
try:
# Handle different update types
if not hasattr(update, "contents") or not update.contents:
return events
for content in update.contents:
content_type = content.__class__.__name__
if content_type in self.content_mappers:
mapped_events = await self.content_mappers[content_type](content, context)
if isinstance(mapped_events, list):
events.extend(mapped_events)
else:
events.append(mapped_events)
else:
# Graceful fallback for unknown content types
events.append(await self._create_unknown_content_event(content, context))
context["content_index"] += 1
except Exception as e:
logger.warning(f"Error converting agent update: {e}")
events.append(await self._create_error_event(str(e), context))
return events
async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]:
"""Convert workflow event to structured OpenAI events.
Args:
event: Workflow event
context: Conversion context
Returns:
List of OpenAI response stream events
"""
try:
# Create structured workflow event
workflow_event = ResponseWorkflowEventComplete(
type="response.workflow_event.complete",
data={
"event_type": event.__class__.__name__,
"data": getattr(event, "data", None),
"executor_id": getattr(event, "executor_id", None),
"timestamp": datetime.now().isoformat(),
},
executor_id=getattr(event, "executor_id", None),
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
return [workflow_event]
except Exception as e:
logger.warning(f"Error converting workflow event: {e}")
return [await self._create_error_event(str(e), context)]
# Content type mappers - implementing our comprehensive mapping plan
async def _map_text_content(self, content: Any, context: dict[str, Any]) -> ResponseTextDeltaEvent:
"""Map TextContent to ResponseTextDeltaEvent."""
return self._create_text_delta_event(content.text, context)
async def _map_reasoning_content(self, content: Any, context: dict[str, Any]) -> ResponseReasoningTextDeltaEvent:
"""Map TextReasoningContent to ResponseReasoningTextDeltaEvent."""
return ResponseReasoningTextDeltaEvent(
type="response.reasoning_text.delta",
delta=content.text,
item_id=context["item_id"],
output_index=context["output_index"],
content_index=context["content_index"],
sequence_number=self._next_sequence(context),
)
async def _map_function_call_content(
self, content: Any, context: dict[str, Any]
) -> list[ResponseFunctionCallArgumentsDeltaEvent]:
"""Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s)."""
events = []
# For streaming, need to chunk the arguments JSON
args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}"
# Chunk the JSON string for streaming
for chunk in self._chunk_json_string(args_str):
events.append(
ResponseFunctionCallArgumentsDeltaEvent(
type="response.function_call_arguments.delta",
delta=chunk,
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
)
return events
async def _map_function_result_content(
self, content: Any, context: dict[str, Any]
) -> ResponseFunctionResultComplete:
"""Map FunctionResultContent to structured event."""
return ResponseFunctionResultComplete(
type="response.function_result.complete",
data={
"call_id": getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"),
"result": getattr(content, "result", None),
"status": "completed" if not getattr(content, "exception", None) else "failed",
"exception": str(getattr(content, "exception", None)) if getattr(content, "exception", None) else None,
"timestamp": datetime.now().isoformat(),
},
call_id=getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"),
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_error_content(self, content: Any, context: dict[str, Any]) -> ResponseErrorEvent:
"""Map ErrorContent to ResponseErrorEvent."""
return ResponseErrorEvent(
type="error",
message=getattr(content, "message", "Unknown error"),
code=getattr(content, "error_code", None),
param=None,
sequence_number=self._next_sequence(context),
)
async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> ResponseUsageEventComplete:
"""Map UsageContent to structured usage event."""
# Store usage data in context for aggregation
if "usage_data" not in context:
context["usage_data"] = []
context["usage_data"].append(content)
return ResponseUsageEventComplete(
type="response.usage.complete",
data={
"usage_data": getattr(content, "usage_data", {}),
"total_tokens": getattr(content, "total_tokens", 0),
"completion_tokens": getattr(content, "completion_tokens", 0),
"prompt_tokens": getattr(content, "prompt_tokens", 0),
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map DataContent to structured trace event."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "data",
"data": getattr(content, "data", None),
"mime_type": getattr(content, "mime_type", "application/octet-stream"),
"size_bytes": len(str(getattr(content, "data", ""))) if getattr(content, "data", None) else 0,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_uri_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map UriContent to structured trace event."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "uri",
"uri": getattr(content, "uri", ""),
"mime_type": getattr(content, "mime_type", "text/plain"),
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_hosted_file_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map HostedFileContent to structured trace event."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "hosted_file",
"file_id": getattr(content, "file_id", "unknown"),
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_hosted_vector_store_content(
self, content: Any, context: dict[str, Any]
) -> ResponseTraceEventComplete:
"""Map HostedVectorStoreContent to structured trace event."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "hosted_vector_store",
"vector_store_id": getattr(content, "vector_store_id", "unknown"),
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
"""Map FunctionApprovalRequestContent to custom event."""
return {
"type": "response.function_approval.requested",
"request_id": getattr(content, "id", "unknown"),
"function_call": {
"id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "",
"name": getattr(content.function_call, "name", "") if hasattr(content, "function_call") else "",
"arguments": getattr(content.function_call, "arguments", {})
if hasattr(content, "function_call")
else {},
},
"item_id": context["item_id"],
"output_index": context["output_index"],
"sequence_number": self._next_sequence(context),
}
async def _map_approval_response_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
"""Map FunctionApprovalResponseContent to custom event."""
return {
"type": "response.function_approval.responded",
"request_id": getattr(content, "request_id", "unknown"),
"approved": getattr(content, "approved", False),
"item_id": context["item_id"],
"output_index": context["output_index"],
"sequence_number": self._next_sequence(context),
}
# Helper methods
def _create_text_delta_event(self, text: str, context: dict[str, Any]) -> ResponseTextDeltaEvent:
"""Create a ResponseTextDeltaEvent."""
return ResponseTextDeltaEvent(
type="response.output_text.delta",
item_id=context["item_id"],
output_index=context["output_index"],
content_index=context["content_index"],
delta=text,
sequence_number=self._next_sequence(context),
logprobs=[],
)
async def _create_error_event(self, message: str, context: dict[str, Any]) -> ResponseErrorEvent:
"""Create a ResponseErrorEvent."""
return ResponseErrorEvent(
type="error", message=message, code=None, param=None, sequence_number=self._next_sequence(context)
)
async def _create_unknown_event(self, event_data: Any, context: dict[str, Any]) -> ResponseStreamEvent:
"""Create event for unknown event types."""
text = f"Unknown event: {event_data!s}\\n"
return self._create_text_delta_event(text, context)
async def _create_unknown_content_event(self, content: Any, context: dict[str, Any]) -> ResponseStreamEvent:
"""Create event for unknown content types."""
content_type = content.__class__.__name__
text = f"⚠️ Unknown content type: {content_type}\\n"
return self._create_text_delta_event(text, context)
def _chunk_json_string(self, json_str: str, chunk_size: int = 50) -> list[str]:
"""Chunk JSON string for streaming."""
return [json_str[i : i + chunk_size] for i in range(0, len(json_str), chunk_size)]
async def _create_error_response(self, error_message: str, request: AgentFrameworkRequest) -> OpenAIResponse:
"""Create error response."""
error_text = f"Error: {error_message}"
response_output_text = ResponseOutputText(type="output_text", text=error_text, annotations=[])
response_output_message = ResponseOutputMessage(
type="message",
role="assistant",
content=[response_output_text],
id=f"msg_{uuid.uuid4().hex[:8]}",
status="completed",
)
usage = ResponseUsage(
input_tokens=0,
output_tokens=0,
total_tokens=0,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
return OpenAIResponse(
id=f"resp_{uuid.uuid4().hex[:12]}",
object="response",
created_at=datetime.now().timestamp(),
model=request.model,
output=[response_output_message],
usage=usage,
parallel_tool_calls=False,
tool_choice="none",
tools=[],
)
@@ -0,0 +1,397 @@
# Copyright (c) Microsoft. All rights reserved.
"""FastAPI server implementation."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from ._discovery import EntityDiscovery
from ._executor import AgentFrameworkExecutor
from ._mapper import MessageMapper
from .models import AgentFrameworkRequest, OpenAIError
from .models._discovery_models import DiscoveryResponse, EntityInfo
# Removed ExecutionEngine import - using direct executor approach
logger = logging.getLogger(__name__)
class DevServer:
"""Development Server - OpenAI compatible API server for debugging agents."""
def __init__(
self,
entities_dir: str | None = None,
port: int = 8080,
host: str = "127.0.0.1",
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
) -> None:
"""Initialize the development server.
Args:
entities_dir: Directory to scan for entities
port: Port to run server on
host: Host to bind server to
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
"""
self.entities_dir = entities_dir
self.port = port
self.host = host
self.cors_origins = cors_origins or ["*"]
self.ui_enabled = ui_enabled
self.executor: AgentFrameworkExecutor | None = None
self._app: FastAPI | None = None
self._pending_entities: list[Any] | None = None
async def _ensure_executor(self) -> AgentFrameworkExecutor:
"""Ensure executor is initialized."""
if self.executor is None:
logger.info("Initializing Agent Framework executor...")
# Create components directly
entity_discovery = EntityDiscovery(self.entities_dir)
message_mapper = MessageMapper()
self.executor = AgentFrameworkExecutor(entity_discovery, message_mapper)
# Discover entities from directory
discovered_entities = await self.executor.discover_entities()
logger.info(f"Discovered {len(discovered_entities)} entities from directory")
# Register any pending in-memory entities
if self._pending_entities:
discovery = self.executor.entity_discovery
for entity in self._pending_entities:
try:
entity_info = await discovery.create_entity_info_from_object(entity)
discovery.register_entity(entity_info.id, entity_info, entity)
logger.info(f"Registered in-memory entity: {entity_info.id}")
except Exception as e:
logger.error(f"Failed to register in-memory entity: {e}")
self._pending_entities = None # Clear after registration
# Get the final entity count after all registration
all_entities = self.executor.entity_discovery.list_entities()
logger.info(f"Total entities available: {len(all_entities)}")
return self.executor
def create_app(self) -> FastAPI:
"""Create the FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Startup
logger.info("Starting Agent Framework Server")
await self._ensure_executor()
yield
# Shutdown
logger.info("Shutting down Agent Framework Server")
app = FastAPI(
title="Agent Framework Server",
description="OpenAI-compatible API server for Agent Framework and other AI frameworks",
version="1.0.0",
lifespan=lifespan,
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=self.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._register_routes(app)
self._mount_ui(app)
return app
def _register_routes(self, app: FastAPI) -> None:
"""Register API routes."""
@app.get("/health")
async def health_check() -> dict[str, Any]:
"""Health check endpoint."""
executor = await self._ensure_executor()
entities = await executor.discover_entities()
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
@app.get("/v1/entities", response_model=DiscoveryResponse)
async def discover_entities() -> DiscoveryResponse:
"""List all registered entities."""
try:
executor = await self._ensure_executor()
# Use list_entities() instead of discover_entities() to get already-registered entities
entities = executor.entity_discovery.list_entities()
return DiscoveryResponse(entities=entities)
except Exception as e:
logger.error(f"Error listing entities: {e}")
raise HTTPException(status_code=500, detail=f"Entity listing failed: {e!s}") from e
@app.get("/v1/entities/{entity_id}/info", response_model=EntityInfo)
async def get_entity_info(entity_id: str) -> EntityInfo:
"""Get detailed information about a specific entity."""
try:
executor = await self._ensure_executor()
entity_info = executor.get_entity_info(entity_id)
if not entity_info:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
# For workflows, populate additional detailed information
if entity_info.type == "workflow":
entity_obj = executor.entity_discovery.get_entity_object(entity_id)
if entity_obj:
# Get workflow structure
workflow_dump = None
if hasattr(entity_obj, "model_dump"):
workflow_dump = entity_obj.model_dump()
elif hasattr(entity_obj, "__dict__"):
workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")}
# Get input schema information
input_schema = {}
input_type_name = "Unknown"
start_executor_id = ""
try:
start_executor = entity_obj.get_start_executor()
if start_executor and hasattr(start_executor, "_handlers"):
message_types = list(start_executor._handlers.keys())
if message_types:
input_type = message_types[0]
input_type_name = getattr(input_type, "__name__", str(input_type))
# Basic schema generation for common types
if input_type is str:
input_schema = {"type": "string"}
elif input_type is dict:
input_schema = {"type": "object"}
elif hasattr(input_type, "model_json_schema"):
input_schema = input_type.model_json_schema()
start_executor_id = getattr(start_executor, "executor_id", "")
except Exception as e:
logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
# Get executor list
executor_list = []
if hasattr(entity_obj, "executors") and entity_obj.executors:
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
# Create copy of entity info and populate workflow-specific fields
enhanced_info = entity_info.model_copy()
enhanced_info.workflow_dump = workflow_dump
enhanced_info.input_schema = input_schema
enhanced_info.input_type_name = input_type_name
enhanced_info.start_executor_id = start_executor_id
# Update executors field if we found better data
if executor_list:
enhanced_info.executors = executor_list
return enhanced_info
# For non-workflow entities, return as-is
return entity_info
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting entity info for {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e
@app.post("/v1/responses")
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
"""OpenAI Responses API endpoint."""
try:
# Debug: log the incoming request
raw_body = await raw_request.body()
logger.info(f"Raw request body: {raw_body.decode()}")
logger.info(f"Parsed request: model={request.model}, extra_body={request.extra_body}")
# Get entity_id using the new method
entity_id = request.get_entity_id()
logger.info(f"Extracted entity_id: {entity_id}")
if not entity_id:
error = OpenAIError.create(f"Missing entity_id. Request extra_body: {request.extra_body}")
return JSONResponse(status_code=400, content=error.model_dump())
# Get executor and validate entity exists
executor = await self._ensure_executor()
try:
entity_info = executor.get_entity_info(entity_id)
logger.info(f"Found entity: {entity_info.name} ({entity_info.type})")
except Exception:
error = OpenAIError.create(f"Entity not found: {entity_id}")
return JSONResponse(status_code=404, content=error.model_dump())
# Execute request
if request.stream:
return StreamingResponse(
self._stream_execution(executor, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
},
)
return await executor.execute_sync(request)
except Exception as e:
logger.error(f"Error executing request: {e}")
error = OpenAIError.create(f"Execution failed: {e!s}")
return JSONResponse(status_code=500, content=error.model_dump())
@app.post("/v1/threads")
async def create_thread(request_data: dict[str, Any]) -> dict[str, Any]:
"""Create a new thread for an agent."""
try:
agent_id = request_data.get("agent_id")
if not agent_id:
raise HTTPException(status_code=400, detail="agent_id is required")
executor = await self._ensure_executor()
thread_id = executor.create_thread(agent_id)
return {
"id": thread_id,
"object": "thread",
"created_at": int(__import__("time").time()),
"metadata": {"agent_id": agent_id},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating thread: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create thread: {e!s}") from e
@app.get("/v1/threads")
async def list_threads(agent_id: str) -> dict[str, Any]:
"""List threads for an agent."""
try:
executor = await self._ensure_executor()
thread_ids = executor.list_threads_for_agent(agent_id)
# Convert thread IDs to thread objects
threads = []
for thread_id in thread_ids:
threads.append({"id": thread_id, "object": "thread", "agent_id": agent_id})
return {"object": "list", "data": threads}
except Exception as e:
logger.error(f"Error listing threads: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list threads: {e!s}") from e
@app.get("/v1/threads/{thread_id}")
async def get_thread(thread_id: str) -> dict[str, Any]:
"""Get thread information."""
try:
executor = await self._ensure_executor()
# Check if thread exists
thread = executor.get_thread(thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
# Get the agent that owns this thread
agent_id = executor.get_agent_for_thread(thread_id)
return {"id": thread_id, "object": "thread", "agent_id": agent_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting thread {thread_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get thread: {e!s}") from e
@app.delete("/v1/threads/{thread_id}")
async def delete_thread(thread_id: str) -> dict[str, Any]:
"""Delete a thread."""
try:
executor = await self._ensure_executor()
success = executor.delete_thread(thread_id)
if not success:
raise HTTPException(status_code=404, detail="Thread not found")
return {"id": thread_id, "object": "thread.deleted", "deleted": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting thread {thread_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete thread: {e!s}") from e
@app.get("/v1/threads/{thread_id}/messages")
async def get_thread_messages(thread_id: str) -> dict[str, Any]:
"""Get messages from a thread."""
try:
executor = await self._ensure_executor()
# Check if thread exists
thread = executor.get_thread(thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
# Get messages from thread
messages = await executor.get_thread_messages(thread_id)
return {"object": "list", "data": messages, "thread_id": thread_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting messages for thread {thread_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get thread messages: {e!s}") from e
async def _stream_execution(
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str, None]:
"""Stream execution directly through executor."""
try:
# Direct call to executor - simple and clean
async for event in executor.execute_streaming(request):
yield f"data: {event.model_dump_json()}\n\n"
# Send final done event
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Error in streaming execution: {e}")
error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
yield f"data: {error_event}\n\n"
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
from pathlib import Path
ui_dir = Path(__file__).parent / "ui"
if ui_dir.exists() and ui_dir.is_dir() and self.ui_enabled:
app.mount("/", StaticFiles(directory=str(ui_dir), html=True), name="ui")
def register_entities(self, entities: list[Any]) -> None:
"""Register entities to be discovered when server starts.
Args:
entities: List of entity objects to register
"""
if self._pending_entities is None:
self._pending_entities = []
self._pending_entities.extend(entities)
def get_app(self) -> FastAPI:
"""Get the FastAPI application instance."""
if self._app is None:
self._app = self.create_app()
return self._app
@@ -0,0 +1,191 @@
# Copyright (c) Microsoft. All rights reserved.
"""Session management for agent execution tracking."""
import logging
import uuid
from datetime import datetime
from typing import Any
logger = logging.getLogger(__name__)
# Type aliases for better readability
SessionData = dict[str, Any]
RequestRecord = dict[str, Any]
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 str(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.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 = []
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 = []
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."""
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, session_id: str | None = None, entity_id: str | None = None) -> None:
"""Initialize trace collector.
Args:
session_id: Session identifier for context
entity_id: Entity identifier for context
"""
self.session_id = session_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",
"session_id": self.session_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(
session_id: str | None = None, entity_id: str | None = None
) -> Generator[SimpleTraceCollector, None, None]:
"""Context manager to capture traces during execution.
Args:
session_id: Session identifier for context
entity_id: Entity identifier for context
Yields:
SimpleTraceCollector instance to get trace events from
"""
collector = SimpleTraceCollector(session_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 session: {session_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,72 @@
# 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.responses import (
Response,
ResponseErrorEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseInputParam,
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 DiscoveryResponse, EntityInfo
from ._openai_custom import (
AgentFrameworkRequest,
OpenAIError,
ResponseFunctionResultComplete,
ResponseFunctionResultDelta,
ResponseTraceEvent,
ResponseTraceEventComplete,
ResponseTraceEventDelta,
ResponseUsageEventComplete,
ResponseUsageEventDelta,
ResponseWorkflowEventComplete,
ResponseWorkflowEventDelta,
)
# Type alias for compatibility
OpenAIResponse = Response
# Export all types for easy importing
__all__ = [
"AgentFrameworkRequest",
"DiscoveryResponse",
"EntityInfo",
"InputTokensDetails",
"Metadata",
"OpenAIError",
"OpenAIResponse",
"OutputTokensDetails",
"Response",
"ResponseErrorEvent",
"ResponseFunctionCallArgumentsDeltaEvent",
"ResponseFunctionResultComplete",
"ResponseFunctionResultDelta",
"ResponseInputParam",
"ResponseOutputMessage",
"ResponseOutputText",
"ResponseReasoningTextDeltaEvent",
"ResponseStreamEvent",
"ResponseTextDeltaEvent",
"ResponseTraceEvent",
"ResponseTraceEventComplete",
"ResponseTraceEventDelta",
"ResponseUsage",
"ResponseUsageEventComplete",
"ResponseUsageEventDelta",
"ResponseWorkflowEventComplete",
"ResponseWorkflowEventDelta",
"ResponsesModel",
"ToolParam",
]
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
"""Discovery API models for entity information."""
from typing import Any
from pydantic import BaseModel, Field
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)
# 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=list)
@@ -0,0 +1,202 @@
# 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, traces, and function results.
"""
from typing import Any, Literal
from pydantic import BaseModel
# Custom Agent Framework OpenAI event types for structured data
class ResponseWorkflowEventDelta(BaseModel):
"""Structured workflow event with completion tracking."""
type: Literal["response.workflow_event.delta"] = "response.workflow_event.delta"
delta: dict[str, Any]
executor_id: str | None = None
is_complete: bool = False # Track if this is the final part
item_id: str
output_index: int = 0
sequence_number: int
class ResponseWorkflowEventComplete(BaseModel):
"""Complete workflow event data."""
type: Literal["response.workflow_event.complete"] = "response.workflow_event.complete"
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 ResponseFunctionResultDelta(BaseModel):
"""Structured function result with completion tracking."""
type: Literal["response.function_result.delta"] = "response.function_result.delta"
delta: dict[str, Any]
call_id: str
is_complete: bool = False
item_id: str
output_index: int = 0
sequence_number: int
class ResponseFunctionResultComplete(BaseModel):
"""Complete function result data."""
type: Literal["response.function_result.complete"] = "response.function_result.complete"
data: dict[str, Any] # Complete function result data, not delta
call_id: str
item_id: str
output_index: int = 0
sequence_number: int
class ResponseTraceEventDelta(BaseModel):
"""Structured trace event with completion tracking."""
type: Literal["response.trace.delta"] = "response.trace.delta"
delta: dict[str, Any]
span_id: str | None = None
is_complete: bool = False
item_id: str
output_index: int = 0
sequence_number: int
class ResponseTraceEventComplete(BaseModel):
"""Complete trace event data."""
type: Literal["response.trace.complete"] = "response.trace.complete"
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 ResponseUsageEventDelta(BaseModel):
"""Structured usage event with completion tracking."""
type: Literal["response.usage.delta"] = "response.usage.delta"
delta: dict[str, Any]
is_complete: bool = False
item_id: str
output_index: int = 0
sequence_number: int
class ResponseUsageEventComplete(BaseModel):
"""Complete usage event data."""
type: Literal["response.usage.complete"] = "response.usage.complete"
data: dict[str, Any] # Complete usage data, not delta
item_id: str
output_index: int = 0
sequence_number: int
# Agent Framework extension fields
class AgentFrameworkExtraBody(BaseModel):
"""Agent Framework specific routing fields for OpenAI requests."""
entity_id: str
thread_id: str | None = None
input_data: dict[str, Any] | None = None
class Config:
extra = "allow" # Allow additional fields
# Agent Framework Request Model - Extending real OpenAI types
class AgentFrameworkRequest(BaseModel):
"""OpenAI ResponseCreateParams with Agent Framework extensions.
This properly extends the real OpenAI API request format while adding
our custom routing fields in extra_body.
"""
# All OpenAI fields from ResponseCreateParams
model: str
input: str | list[Any] # ResponseInputParam
stream: bool | None = False
# Common OpenAI optional fields
instructions: str | None = None
metadata: dict[str, Any] | None = None
temperature: float | None = None
max_output_tokens: int | None = None
tools: list[dict[str, Any]] | None = None
# Agent Framework extension - strongly typed
extra_body: AgentFrameworkExtraBody | None = None
class Config:
# Allow extra fields from OpenAI spec
extra = "allow"
entity_id: str | None = None # Allow entity_id as top-level field
def get_entity_id(self) -> str | None:
"""Get entity_id from either top-level field or extra_body."""
# Priority 1: Top-level entity_id field
if self.entity_id:
return self.entity_id
# Priority 2: entity_id in extra_body
if self.extra_body and hasattr(self.extra_body, "entity_id"):
return self.extra_body.entity_id
return None
def to_openai_params(self) -> dict[str, Any]:
"""Convert to dict for OpenAI client compatibility."""
data = self.model_dump(exclude={"extra_body", "entity_id"}, exclude_none=True)
if self.extra_body:
# Don't merge extra_body into main params to keep them separate
data["extra_body"] = self.extra_body
return data
# 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)
# Export all custom types
__all__ = [
"AgentFrameworkRequest",
"OpenAIError",
"ResponseFunctionResultComplete",
"ResponseFunctionResultDelta",
"ResponseTraceEvent",
"ResponseTraceEventComplete",
"ResponseTraceEventDelta",
"ResponseUsageEventComplete",
"ResponseUsageEventDelta",
"ResponseWorkflowEventComplete",
"ResponseWorkflowEventDelta",
]
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="/vite.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-BESRiUNX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZfe_njJ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB