Python:DevUI Fixes (#1035)

* fix event reset on thread change, enable multiline input, enable pasting of files and screenshots

* UI updates and improved remove discovery

* ui and other fixes

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-30 17:21:22 -07:00
committed by GitHub
Unverified
parent 042099009f
commit fa9f5c1aed
41 changed files with 2949 additions and 814 deletions
@@ -9,7 +9,7 @@ from typing import Any
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
logger = logging.getLogger(__name__)
@@ -27,6 +27,7 @@ def serve(
auto_open: bool = False,
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
tracing_enabled: bool = False,
) -> None:
"""Launch Agent Framework DevUI with simple API.
@@ -38,6 +39,7 @@ def serve(
auto_open: Whether to automatically open browser
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
tracing_enabled: Whether to enable OpenTelemetry tracing
"""
import re
@@ -51,6 +53,23 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Configure tracing environment variables if enabled
if tracing_enabled:
import os
# Only set if not already configured by user
if not os.environ.get("ENABLE_OTEL"):
os.environ["ENABLE_OTEL"] = "true"
logger.info("Set ENABLE_OTEL=true for tracing")
if not os.environ.get("ENABLE_SENSITIVE_DATA"):
os.environ["ENABLE_SENSITIVE_DATA"] = "true"
logger.info("Set ENABLE_SENSITIVE_DATA=true for tracing")
if not os.environ.get("OTLP_ENDPOINT"):
os.environ["OTLP_ENDPOINT"] = "http://localhost:4317"
logger.info("Set OTLP_ENDPOINT=http://localhost:4317 for tracing")
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
@@ -123,6 +142,7 @@ __all__ = [
"DevServer",
"DiscoveryResponse",
"EntityInfo",
"EnvVarRequirement",
"OpenAIError",
"OpenAIResponse",
"ResponseStreamEvent",
@@ -28,6 +28,7 @@ Examples:
devui ./agents # Scan specific directory
devui --port 8000 # Custom port
devui --headless # API only, no UI
devui --tracing # Enable OpenTelemetry tracing
""",
)
@@ -52,6 +53,8 @@ Examples:
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
parser.add_argument("--tracing", action="store_true", help="Enable OpenTelemetry tracing for Agent Framework")
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
return parser
@@ -119,7 +122,12 @@ def main() -> None:
from . import serve
serve(
entities_dir=entities_dir, port=args.port, host=args.host, auto_open=not args.no_open, ui_enabled=ui_enabled
entities_dir=entities_dir,
port=args.port,
host=args.host,
auto_open=not args.no_open,
ui_enabled=ui_enabled,
tracing_enabled=args.tracing,
)
except KeyboardInterrupt:
@@ -2,6 +2,9 @@
"""Agent Framework entity discovery implementation."""
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import logging
@@ -10,6 +13,7 @@ import uuid
from pathlib import Path
from typing import Any
import httpx
from dotenv import load_dotenv
from .models._discovery_models import EntityInfo
@@ -29,6 +33,7 @@ class EntityDiscovery:
self.entities_dir = entities_dir
self._entities: dict[str, EntityInfo] = {}
self._loaded_objects: dict[str, Any] = {}
self._remote_cache_dir = Path.home() / ".agent_framework_devui" / "remote_cache"
async def discover_entities(self) -> list[EntityInfo]:
"""Scan for Agent Framework entities.
@@ -88,12 +93,15 @@ class EntityDiscovery:
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:
async def create_entity_info_from_object(
self, entity_object: Any, entity_type: str | None = None, source: str = "in_memory"
) -> EntityInfo:
"""Create EntityInfo from Agent Framework entity object.
Args:
entity_object: Agent Framework entity object
entity_type: Optional entity type override
source: Source of entity (directory, in_memory, remote)
Returns:
EntityInfo with Agent Framework specific metadata
@@ -121,7 +129,7 @@ class EntityDiscovery:
description = getattr(entity_object, "description", "")
# Generate entity ID using Agent Framework specific naming
entity_id = self._generate_entity_id(entity_object, entity_type)
entity_id = self._generate_entity_id(entity_object, entity_type, source)
# Extract tools/executors using Agent Framework specific logic
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
@@ -343,9 +351,9 @@ class EntityDiscovery:
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)
# Pass source as "directory" for directory-discovered entities
await self._register_entity_from_object(obj, obj_type, module_path, source="directory")
entities_found.append(obj_type)
return entities_found
@@ -405,35 +413,31 @@ class EntityDiscovery:
# 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:
async def _register_entity_from_object(
self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
) -> None:
"""Register an entity from a live object.
Args:
entity_id: Unique entity identifier
obj: Entity object
obj_type: Type of entity ("agent" or "workflow")
module_path: Path to module for metadata
source: Source of entity (directory, in_memory, remote)
"""
try:
# Generate entity ID with source information
entity_id = self._generate_entity_id(obj, obj_type, source)
# Extract metadata from the live object with improved fallback naming
name = getattr(obj, "name", None)
if not name:
# 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()
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:
# 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__}"
name = f"{obj_type.title()} {obj.__class__.__name__}"
description = getattr(obj, "description", None)
tools = await self._extract_tools_from_object(obj, obj_type)
@@ -452,7 +456,7 @@ class EntityDiscovery:
metadata={
"module_path": module_path,
"entity_type": obj_type,
"source": "module_import",
"source": source,
"has_run_stream": hasattr(obj, "run_stream"),
"class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
},
@@ -462,7 +466,7 @@ class EntityDiscovery:
self.register_entity(entity_id, entity_info, obj)
except Exception as e:
logger.error(f"Error registering entity {entity_id}: {e}")
logger.error(f"Error registering entity from {source}: {e}")
async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
"""Extract tool/executor names from a live object.
@@ -517,34 +521,204 @@ class EntityDiscovery:
return tools
def _generate_entity_id(self, entity: Any, entity_type: str) -> str:
"""Generate entity ID with priority: name -> id -> class_name -> uuid.
def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
"""Generate unique entity ID with UUID suffix for collision resistance.
Args:
entity: Entity object
entity_type: Type of entity (agent, workflow, etc.)
source: Source of entity (directory, in_memory, remote)
Returns:
Generated entity ID
Unique entity ID with format: {type}_{source}_{name}_{uuid8}
"""
import re
# Priority 1: entity.name
# Extract base name with priority: name -> id -> class_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__"):
base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
elif hasattr(entity, "id") and entity.id:
base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
elif hasattr(entity, "__class__"):
class_name = entity.__class__.__name__
# Convert CamelCase to kebab-case
class_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
return f"{entity_type}_{class_name}"
base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
else:
base_name = "entity"
# Priority 4: fallback to uuid
return f"{entity_type}_{uuid.uuid4().hex[:8]}"
# Generate short UUID (8 chars = 4 billion combinations)
short_uuid = uuid.uuid4().hex[:8]
return f"{entity_type}_{source}_{base_name}_{short_uuid}"
async def fetch_remote_entity(
self, url: str, metadata: dict[str, Any] | None = None
) -> tuple[EntityInfo | None, str | None]:
"""Fetch and register entity from URL.
Args:
url: URL to Python file containing entity
metadata: Additional metadata (source, sampleId, etc.)
Returns:
Tuple of (EntityInfo if successful, error_message if failed)
"""
try:
normalized_url = self._normalize_url(url)
logger.info(f"Normalized URL: {normalized_url}")
content = await self._fetch_url_content(normalized_url)
if not content:
error_msg = "Failed to fetch content from URL. The file may not exist or is not accessible."
logger.warning(error_msg)
return None, error_msg
if not self._validate_python_syntax(content):
error_msg = "Invalid Python syntax in the file. Please check the file contains valid Python code."
logger.warning(error_msg)
return None, error_msg
entity_object = await self._load_entity_from_content(content, url)
if not entity_object:
error_msg = (
"No valid agent or workflow found in the file. "
"Make sure the file contains an 'agent' or 'workflow' variable."
)
logger.warning(error_msg)
return None, error_msg
entity_info = await self.create_entity_info_from_object(
entity_object,
entity_type=None, # Auto-detect
source="remote",
)
entity_info.source = metadata.get("source", "remote_gallery") if metadata else "remote_gallery"
entity_info.original_url = url
if metadata:
entity_info.metadata.update(metadata)
self.register_entity(entity_info.id, entity_info, entity_object)
logger.info(f"Successfully added remote entity: {entity_info.id}")
return entity_info, None
except Exception as e:
error_msg = f"Unexpected error: {e!s}"
logger.error(f"Error fetching remote entity from {url}: {e}", exc_info=True)
return None, error_msg
def _normalize_url(self, url: str) -> str:
"""Convert various Git hosting URLs to raw content URLs."""
# GitHub: blob -> raw
if "github.com" in url and "/blob/" in url:
return url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
# GitLab: blob -> raw
if "gitlab.com" in url and "/-/blob/" in url:
return url.replace("/-/blob/", "/-/raw/")
# Bitbucket: src -> raw
if "bitbucket.org" in url and "/src/" in url:
return url.replace("/src/", "/raw/")
return url
async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None:
"""Fetch content from URL with size and timeout limits."""
try:
timeout = 30.0 # 30 second timeout
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url)
if response.status_code != 200:
logger.warning(f"HTTP {response.status_code} for {url}")
return None
# Check content length
content_length = response.headers.get("content-length")
if content_length and int(content_length) > max_size_mb * 1024 * 1024:
logger.warning(f"File too large: {content_length} bytes")
return None
# Read with size limit
content = response.text
if len(content.encode("utf-8")) > max_size_mb * 1024 * 1024:
logger.warning("Content too large after reading")
return None
return content
except Exception as e:
logger.error(f"Error fetching {url}: {e}")
return None
def _validate_python_syntax(self, content: str) -> bool:
"""Validate that content is valid Python code."""
try:
compile(content, "<remote>", "exec")
return True
except SyntaxError as e:
logger.warning(f"Python syntax error: {e}")
return False
async def _load_entity_from_content(self, content: str, source_url: str) -> Any | None:
"""Load entity object from Python content string using disk-based import.
This method caches remote entities to disk and uses importlib for loading,
making it consistent with local entity discovery and avoiding exec() security warnings.
"""
try:
# Create cache directory if it doesn't exist
self._remote_cache_dir.mkdir(parents=True, exist_ok=True)
# Generate a unique filename based on URL hash
url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
module_name = f"remote_entity_{url_hash}"
cached_file = self._remote_cache_dir / f"{module_name}.py"
# Write content to cache file
cached_file.write_text(content, encoding="utf-8")
logger.debug(f"Cached remote entity to {cached_file}")
# Load module from cached file using importlib (same as local scanning)
module = self._load_module_from_file(cached_file, module_name)
if not module:
logger.warning(f"Failed to load module from cached file: {cached_file}")
return None
# Look for agent or workflow objects in the loaded module
for name in dir(module):
if name.startswith("_"):
continue
obj = getattr(module, name)
# Check for explicitly named entities first
if name in ["agent", "workflow"] and self._is_valid_entity(obj, name):
return obj
# Also check if any object looks like an agent/workflow
if self._is_valid_agent(obj) or self._is_valid_workflow(obj):
return obj
return None
except Exception as e:
logger.error(f"Error loading entity from content: {e}")
return None
def remove_remote_entity(self, entity_id: str) -> bool:
"""Remove a remote entity by ID."""
if entity_id in self._entities:
entity_info = self._entities[entity_id]
if entity_info.source in ["remote_gallery", "remote"]:
del self._entities[entity_id]
if entity_id in self._loaded_objects:
del self._loaded_objects[entity_id]
logger.info(f"Removed remote entity: {entity_id}")
return True
logger.warning(f"Cannot remove local entity: {entity_id}")
return False
return False
@@ -71,18 +71,17 @@ class AgentFrameworkExecutor:
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("OTLP_ENDPOINT")
if otlp_endpoint:
# Configure Agent Framework tracing only if ENABLE_OTEL is set
if os.environ.get("ENABLE_OTEL"):
try:
from agent_framework.observability import setup_observability
setup_observability(enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
logger.info(f"Enabled Agent Framework observability with endpoint: {otlp_endpoint}")
setup_observability(enable_sensitive_data=True)
logger.info("Enabled Agent Framework observability")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework observability: {e}")
else:
logger.debug("No OTLP endpoint configured, skipping observability setup")
logger.debug("ENABLE_OTEL not set, skipping observability setup")
# Thread Management Methods
def create_thread(self, agent_id: str) -> str:
@@ -118,7 +117,6 @@ class AgentFrameworkExecutor:
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)
@@ -128,7 +126,7 @@ class AgentFrameworkExecutor:
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."""
"""Get messages from a thread's message store, preserving all content types for UI display."""
thread = self.get_thread(thread_id)
if not thread or not thread.message_store:
return []
@@ -142,21 +140,21 @@ class AgentFrameworkExecutor:
# 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
# Skip tool/function messages - only show user and assistant messages
if role not in ["user", "assistant"]:
continue
# Extract user-facing text content only
text_content = self._extract_display_text(af_msg.contents)
# Extract all user-facing content (text, images, files, etc.)
display_contents = self._extract_display_contents(af_msg.contents)
# Skip messages with no displayable text
if not text_content:
# Skip messages with no displayable content
if not display_contents:
continue
ui_message = {
"id": af_msg.message_id or f"restored-{i}",
"role": role,
"contents": [{"type": "text", "text": text_content}],
"contents": display_contents,
"timestamp": __import__("datetime").datetime.now().isoformat(),
"author_name": af_msg.author_name,
"message_id": af_msg.message_id,
@@ -174,14 +172,18 @@ class AgentFrameworkExecutor:
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 = []
def _extract_display_contents(self, contents: list[Any]) -> list[dict[str, Any]]:
"""Extract all user-facing content (text, images, files, etc.) from message contents.
Filters out internal mechanics like function calls/results while preserving
all content types that should be displayed in the UI.
"""
display_contents = []
for content in contents:
content_type = getattr(content, "type", None)
# Only include text content for display
# Text content
if content_type == "text":
text = getattr(content, "text", "")
@@ -194,15 +196,31 @@ class AgentFrameworkExecutor:
if parsed.get("contents"):
for sub_content in parsed["contents"]:
if sub_content.get("type") == "text":
text_parts.append(sub_content.get("text", ""))
display_contents.append({"type": "text", "text": sub_content.get("text", "")})
except Exception:
text_parts.append(text) # Fallback to raw text
display_contents.append({"type": "text", "text": text})
else:
text_parts.append(text)
display_contents.append({"type": "text", "text": text})
# Data content (images, files, PDFs, etc.)
elif content_type == "data":
display_contents.append({
"type": "data",
"uri": getattr(content, "uri", ""),
"media_type": getattr(content, "media_type", None),
})
# URI content (external links to images/files)
elif content_type == "uri":
display_contents.append({
"type": "uri",
"uri": getattr(content, "uri", ""),
"media_type": getattr(content, "media_type", None),
})
# Skip function_call, function_result, and other internal content types
return " ".join(text_parts).strip()
return display_contents
async def serialize_thread(self, thread_id: str) -> dict[str, Any] | None:
"""Serialize thread state for persistence."""
@@ -380,7 +398,6 @@ class AgentFrameworkExecutor:
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:
@@ -389,19 +406,15 @@ class AgentFrameworkExecutor:
# 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:
@@ -546,6 +559,17 @@ class AgentFrameworkExecutor:
media_type = "application/pdf"
elif filename.lower().endswith((".png", ".jpg", ".jpeg", ".gif")):
media_type = f"image/{filename.split('.')[-1].lower()}"
elif filename.lower().endswith((
".wav",
".mp3",
".m4a",
".ogg",
".flac",
".aac",
)):
ext = filename.split(".")[-1].lower()
# Normalize extensions to match audio MIME types
media_type = "audio/mp4" if ext == "m4a" else f"audio/{ext}"
# Use file_data or file_url
if file_data:
@@ -562,8 +586,17 @@ class AgentFrameworkExecutor:
if not contents:
contents.append(TextContent(text=""))
# Create ChatMessage with user role
return ChatMessage(role=Role.USER, contents=contents)
chat_message = ChatMessage(role=Role.USER, contents=contents)
logger.info(f"Created ChatMessage with {len(contents)} contents:")
for idx, content in enumerate(contents):
content_type = content.__class__.__name__
if hasattr(content, "media_type"):
logger.info(f" [{idx}] {content_type} - media_type: {content.media_type}")
else:
logger.info(f" [{idx}] {content_type}")
return chat_message
def _extract_user_message_fallback(self, input_data: Any) -> str:
"""Fallback method to extract user message as string.
@@ -2,6 +2,7 @@
"""FastAPI server implementation."""
import inspect
import json
import logging
from collections.abc import AsyncGenerator
@@ -19,8 +20,6 @@ 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__)
@@ -72,7 +71,7 @@ class DevServer:
discovery = self.executor.entity_discovery
for entity in self._pending_entities:
try:
entity_info = await discovery.create_entity_info_from_object(entity)
entity_info = await discovery.create_entity_info_from_object(entity, source="in-memory")
discovery.register_entity(entity_info.id, entity_info, entity)
logger.info(f"Registered in-memory entity: {entity_info.id}")
except Exception as e:
@@ -85,6 +84,33 @@ class DevServer:
return self.executor
async def _cleanup_entities(self) -> None:
"""Cleanup entity resources (close clients, credentials, etc.)."""
if not self.executor:
return
logger.info("Cleaning up entity resources...")
entities = self.executor.entity_discovery.list_entities()
closed_count = 0
for entity_info in entities:
try:
entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
if hasattr(client, "close") and callable(client.close):
if inspect.iscoroutinefunction(client.close):
await client.close()
else:
client.close()
closed_count += 1
logger.debug(f"Closed client for entity: {entity_info.id}")
except Exception as e:
logger.warning(f"Error closing entity {entity_info.id}: {e}")
if closed_count > 0:
logger.info(f"Closed {closed_count} entity client(s)")
def create_app(self) -> FastAPI:
"""Create the FastAPI application."""
@@ -97,6 +123,10 @@ class DevServer:
# Shutdown
logger.info("Shutting down Agent Framework Server")
# Cleanup entity resources (e.g., close credentials, clients)
if self.executor:
await self._cleanup_entities()
app = FastAPI(
title="Agent Framework Server",
description="OpenAI-compatible API server for Agent Framework and other AI frameworks",
@@ -125,7 +155,8 @@ class DevServer:
async def health_check() -> dict[str, Any]:
"""Health check endpoint."""
executor = await self._ensure_executor()
entities = await executor.discover_entities()
# Use list_entities() to avoid re-discovering and re-registering entities
entities = executor.entity_discovery.list_entities()
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
@@ -235,11 +266,75 @@ class DevServer:
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/entities/add")
async def add_entity(request: dict[str, Any]) -> dict[str, Any]:
"""Add entity from URL."""
try:
url = request.get("url")
metadata = request.get("metadata", {})
if not url:
raise HTTPException(status_code=400, detail="URL is required")
logger.info(f"Attempting to add entity from URL: {url}")
executor = await self._ensure_executor()
entity_info, error_msg = await executor.entity_discovery.fetch_remote_entity(url, metadata)
if not entity_info:
# Sanitize error message - only return safe, user-friendly errors
logger.error(f"Failed to fetch or validate entity from {url}: {error_msg}")
safe_error = error_msg if error_msg else "Failed to fetch or validate entity"
raise HTTPException(status_code=400, detail=safe_error)
logger.info(f"Successfully added entity: {entity_info.id}")
return {"success": True, "entity": entity_info.model_dump()}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding entity: {e}", exc_info=True)
# Don't expose internal error details to client
raise HTTPException(
status_code=500, detail="An unexpected error occurred while adding the entity"
) from e
@app.delete("/v1/entities/{entity_id}")
async def remove_entity(entity_id: str) -> dict[str, Any]:
"""Remove entity by ID."""
try:
executor = await self._ensure_executor()
# Cleanup entity resources before removal
try:
entity_obj = executor.entity_discovery.get_entity_object(entity_id)
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
if hasattr(client, "close") and callable(client.close):
if inspect.iscoroutinefunction(client.close):
await client.close()
else:
client.close()
logger.info(f"Closed client for entity: {entity_id}")
except Exception as e:
logger.warning(f"Error closing entity {entity_id} during removal: {e}")
# Remove entity from registry
success = executor.entity_discovery.remove_remote_entity(entity_id)
if success:
return {"success": True}
raise HTTPException(status_code=404, detail="Entity not found or cannot be removed")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error removing entity {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to remove entity: {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}")
@@ -385,15 +480,21 @@ class DevServer:
try:
# Direct call to executor - simple and clean
async for event in executor.execute_streaming(request):
if hasattr(event, "to_json") and callable(getattr(event, "to_json", None)):
payload = event.to_json() # type: ignore[attr-defined]
elif hasattr(event, "model_dump_json"):
# IMPORTANT: Check model_dump_json FIRST because to_json() can have newlines (pretty-printing)
# which breaks SSE format. model_dump_json() returns single-line JSON.
if hasattr(event, "model_dump_json"):
payload = event.model_dump_json() # type: ignore[attr-defined]
elif hasattr(event, "to_json") and callable(getattr(event, "to_json", None)):
payload = event.to_json() # type: ignore[attr-defined]
# Strip newlines from pretty-printed JSON for SSE compatibility
payload = payload.replace("\n", "").replace("\r", "")
elif isinstance(event, dict):
# Handle plain dict events (e.g., error events from executor)
payload = json.dumps(event)
elif hasattr(event, "to_dict") and callable(getattr(event, "to_dict", None)):
payload = json.dumps(event.to_dict()) # type: ignore[attr-defined]
else:
if hasattr(event, "to_dict") and callable(getattr(event, "to_dict", None)):
payload = json.dumps(event.to_dict()) # type: ignore[attr-defined]
else:
payload = json.dumps(str(event))
payload = json.dumps(str(event))
yield f"data: {payload}\n\n"
# Send final done event
@@ -402,7 +503,7 @@ class DevServer:
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"
yield f"data: {json.dumps(error_event)}\n\n"
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
@@ -2,11 +2,22 @@
"""Discovery API models for entity information."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class EnvVarRequirement(BaseModel):
"""Environment variable requirement for an entity."""
name: str
description: str
required: bool = True
example: str | None = None
class EntityInfo(BaseModel):
"""Entity information for discovery and detailed views."""
@@ -19,6 +30,13 @@ class EntityInfo(BaseModel):
tools: list[str | dict[str, Any]] | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
# Source information
source: str = "directory" # "directory", "in_memory", "remote_gallery"
original_url: str | None = None
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
# Workflow-specific fields (populated only for detailed info requests)
executors: list[str] | None = None
workflow_dump: dict[str, Any] | None = None
@@ -6,6 +6,8 @@ These are custom event types that extend beyond the standard OpenAI Responses AP
to support Agent Framework specific features like workflows, traces, and function results.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
@@ -177,7 +179,7 @@ class OpenAIError(BaseModel):
error: dict[str, Any]
@classmethod
def create(cls, message: str, type: str = "invalid_request_error", code: str | None = None) -> "OpenAIError":
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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,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">
<script type="module" crossorigin src="/assets/index-DPEaaIdK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1AmQWga.css">
</head>
<body>
<div id="root"></div>