mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
adb6dcd2af
commit
1ef24d3e91
@@ -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 |
Reference in New Issue
Block a user