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