mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI - Internal Refactor, Conversations API support, and per… (#1235)
* Python: DevUI - Internal Refactor, Conversations API support, and performance improvements Comprehensive refactor of DevUI package including samples relocation, frontend reorganization, OpenAI Conversations API support, and critical performance and code quality improvements. Key Changes: Architecture & Organization - Moved DevUI samples to python/samples/getting_started/devui/ - Consolidated with other framework samples for better discoverability - Added .env.example files and comprehensive README - Restructured frontend components into feature-based folders (agent, workflow, gallery, layout) - Created new OpenAI-compliant message renderers (devui should render oai responses types primarily) New Features - Added _conversations.py (467 lines) - Full conversation storage abstraction, replaces the /threads endpoint to better match oai conversations api - Implements OpenAI Conversations API for thread management, Supports in-memory and extensible storage backends API Simplification - Use 'model' field as entity_id (agent/workflow name) instead of extra_body - Use standard OpenAI 'conversation' field for conversation context. Performance & Quality Improvements - Improved context management in MessageMapper with bounded memory (~500KB max) - Implemented hybrid LRU + cleanup approach to prevent unbounded memory growth - General QOL improvement - Eliminated ~150 lines of dead/duplicate code, Consolidated helper functions into _utils.py, Extracted magic numbers to module-level constants, Optimized conversation item lookups with index-based approach Testing - Added test_conversations.py (13 tests) - Added test_performance_fixes.py (9 tests) - Updated existing tests for code consolidation - 53 tests passing Impact: 76 files changed: +4,106 insertions, -2,373 deletions All linting and formatting checks passing. No breaking changes - backward compatible. Migration: Samples moved to python/samples/getting_started/devui/ * readme lint fixes * initial support for function approval and minor ui fixes
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Conversation storage abstraction for OpenAI Conversations API.
|
||||
|
||||
This module provides a clean abstraction layer for managing conversations
|
||||
while wrapping AgentFramework's AgentThread underneath.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import AgentThread, ChatMessage
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.conversations.message import Message
|
||||
from openai.types.conversations.text_content import TextContent
|
||||
from openai.types.responses import (
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputFile,
|
||||
ResponseInputImage,
|
||||
)
|
||||
|
||||
# Type alias for OpenAI Message role literals
|
||||
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""Abstract base class for conversation storage.
|
||||
|
||||
Provides OpenAI Conversations API interface while managing
|
||||
AgentThread instances underneath.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
|
||||
"""Create a new conversation (wraps AgentThread creation).
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
|
||||
|
||||
Returns:
|
||||
Conversation object with generated ID
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Conversation object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
metadata: New metadata dict
|
||||
|
||||
Returns:
|
||||
Updated Conversation object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation (including AgentThread).
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
ConversationDeletedResource object
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation (syncs to AgentThread.message_store).
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
items: List of conversation items to add
|
||||
|
||||
Returns:
|
||||
List of added ConversationItem objects
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items from AgentThread.message_store.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
limit: Maximum number of items to return
|
||||
after: Cursor for pagination (item_id)
|
||||
order: Sort order ("asc" or "desc")
|
||||
|
||||
Returns:
|
||||
Tuple of (items list, has_more boolean)
|
||||
|
||||
Raises:
|
||||
ValueError: If conversation not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get specific conversation item.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
ConversationItem or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_thread(self, conversation_id: str) -> AgentThread | None:
|
||||
"""Get underlying AgentThread for execution (internal use).
|
||||
|
||||
This is the critical method that allows the executor to get the
|
||||
AgentThread for running agents with conversation context.
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
AgentThread object or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id).
|
||||
|
||||
Args:
|
||||
metadata_filter: Metadata key-value pairs to match
|
||||
|
||||
Returns:
|
||||
List of matching Conversation objects
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class InMemoryConversationStore(ConversationStore):
|
||||
"""In-memory conversation storage wrapping AgentThread.
|
||||
|
||||
This implementation stores conversations in memory with their
|
||||
underlying AgentThread instances for execution.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize in-memory conversation storage.
|
||||
|
||||
Storage structure maps conversation IDs to conversation data including
|
||||
the underlying AgentThread, metadata, and cached ConversationItems.
|
||||
"""
|
||||
self._conversations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}}
|
||||
self._item_index: dict[str, dict[str, ConversationItem]] = {}
|
||||
|
||||
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
|
||||
"""Create a new conversation with underlying AgentThread."""
|
||||
conv_id = f"conv_{uuid.uuid4().hex}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Create AgentThread with default ChatMessageStore
|
||||
thread = AgentThread()
|
||||
|
||||
self._conversations[conv_id] = {
|
||||
"id": conv_id,
|
||||
"thread": thread,
|
||||
"metadata": metadata or {},
|
||||
"created_at": created_at,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
# Initialize item index for this conversation
|
||||
self._item_index[conv_id] = {}
|
||||
|
||||
return Conversation(id=conv_id, object="conversation", created_at=created_at, metadata=metadata)
|
||||
|
||||
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||
"""Retrieve conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
return None
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_data.get("metadata"),
|
||||
)
|
||||
|
||||
def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation:
|
||||
"""Update conversation metadata."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
conv_data["metadata"] = metadata
|
||||
|
||||
return Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
|
||||
"""Delete conversation and its AgentThread."""
|
||||
if conversation_id not in self._conversations:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
del self._conversations[conversation_id]
|
||||
# Cleanup item index
|
||||
self._item_index.pop(conversation_id, None)
|
||||
|
||||
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
|
||||
|
||||
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
|
||||
"""Add items to conversation and sync to AgentThread."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
thread: AgentThread = conv_data["thread"]
|
||||
|
||||
# Convert items to ChatMessages and add to thread
|
||||
chat_messages = []
|
||||
for item in items:
|
||||
# Simple conversion - assume text content for now
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", [])
|
||||
text = content[0].get("text", "") if content else ""
|
||||
|
||||
chat_msg = ChatMessage(role=role, contents=[{"type": "text", "text": text}])
|
||||
chat_messages.append(chat_msg)
|
||||
|
||||
# Add messages to AgentThread
|
||||
await thread.on_new_messages(chat_messages)
|
||||
|
||||
# Create Message objects (ConversationItem is a Union - use concrete Message type)
|
||||
conv_items: list[ConversationItem] = []
|
||||
for msg in chat_messages:
|
||||
item_id = f"item_{uuid.uuid4().hex}"
|
||||
|
||||
# Extract role - handle both string and enum
|
||||
role_str = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
|
||||
|
||||
# Convert ChatMessage contents to OpenAI TextContent format
|
||||
message_content = []
|
||||
for content_item in msg.contents:
|
||||
if hasattr(content_item, "type") and content_item.type == "text":
|
||||
# Extract text from TextContent object
|
||||
text_value = getattr(content_item, "text", "")
|
||||
message_content.append(TextContent(type="text", text=text_value))
|
||||
|
||||
# Create Message object (concrete type from ConversationItem union)
|
||||
message = Message(
|
||||
id=item_id,
|
||||
type="message", # Required discriminator for union
|
||||
role=role,
|
||||
content=message_content,
|
||||
status="completed", # Required field
|
||||
)
|
||||
conv_items.append(message)
|
||||
|
||||
# Cache items
|
||||
conv_data["items"].extend(conv_items)
|
||||
|
||||
# Update item index for O(1) lookup
|
||||
if conversation_id not in self._item_index:
|
||||
self._item_index[conversation_id] = {}
|
||||
|
||||
for conv_item in conv_items:
|
||||
if conv_item.id: # Guard against None
|
||||
self._item_index[conversation_id][conv_item.id] = conv_item
|
||||
|
||||
return conv_items
|
||||
|
||||
async def list_items(
|
||||
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> tuple[list[ConversationItem], bool]:
|
||||
"""List conversation items from AgentThread message store.
|
||||
|
||||
Converts AgentFramework ChatMessages to proper OpenAI ConversationItem types:
|
||||
- Messages with text/images/files → Message
|
||||
- Function calls → ResponseFunctionToolCallItem
|
||||
- Function results → ResponseFunctionToolCallOutputItem
|
||||
"""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
if not conv_data:
|
||||
raise ValueError(f"Conversation {conversation_id} not found")
|
||||
|
||||
thread: AgentThread = conv_data["thread"]
|
||||
|
||||
# Get messages from thread's message store
|
||||
items: list[ConversationItem] = []
|
||||
if thread.message_store:
|
||||
af_messages = await thread.message_store.list_messages()
|
||||
|
||||
# Convert each AgentFramework ChatMessage to appropriate ConversationItem type(s)
|
||||
for i, msg in enumerate(af_messages):
|
||||
item_id = f"item_{i}"
|
||||
role_str = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
|
||||
|
||||
# Process each content item in the message
|
||||
# A single ChatMessage may produce multiple ConversationItems
|
||||
# (e.g., a message with both text and a function call)
|
||||
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
|
||||
function_calls = []
|
||||
function_results = []
|
||||
|
||||
for content in msg.contents:
|
||||
content_type = getattr(content, "type", None)
|
||||
|
||||
if content_type == "text":
|
||||
# Text content for Message
|
||||
text_value = getattr(content, "text", "")
|
||||
message_contents.append(TextContent(type="text", text=text_value))
|
||||
|
||||
elif content_type == "data":
|
||||
# Data content (images, files, PDFs)
|
||||
uri = getattr(content, "uri", "")
|
||||
media_type = getattr(content, "media_type", None)
|
||||
|
||||
if media_type and media_type.startswith("image/"):
|
||||
# Convert to ResponseInputImage
|
||||
message_contents.append(
|
||||
ResponseInputImage(type="input_image", image_url=uri, detail="auto")
|
||||
)
|
||||
else:
|
||||
# Convert to ResponseInputFile
|
||||
# Extract filename from URI if possible
|
||||
filename = None
|
||||
if media_type == "application/pdf":
|
||||
filename = "document.pdf"
|
||||
|
||||
message_contents.append(
|
||||
ResponseInputFile(type="input_file", file_url=uri, filename=filename)
|
||||
)
|
||||
|
||||
elif content_type == "function_call":
|
||||
# Function call - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
name = getattr(content, "name", "")
|
||||
arguments = getattr(content, "arguments", "")
|
||||
|
||||
if call_id and name:
|
||||
function_calls.append(
|
||||
ResponseFunctionToolCallItem(
|
||||
id=f"{item_id}_call_{call_id}",
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
type="function_call",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
elif content_type == "function_result":
|
||||
# Function result - create separate ConversationItem
|
||||
call_id = getattr(content, "call_id", None)
|
||||
# Output is stored in additional_properties
|
||||
output = ""
|
||||
if hasattr(content, "additional_properties"):
|
||||
output = content.additional_properties.get("output", "")
|
||||
|
||||
if call_id:
|
||||
function_results.append(
|
||||
ResponseFunctionToolCallOutputItem(
|
||||
id=f"{item_id}_result_{call_id}",
|
||||
call_id=call_id,
|
||||
output=output,
|
||||
type="function_call_output",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Create ConversationItems based on what we found
|
||||
# If message has text/images/files, create a Message item
|
||||
if message_contents:
|
||||
message = Message(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role=role, # type: ignore
|
||||
content=message_contents, # type: ignore
|
||||
status="completed",
|
||||
)
|
||||
items.append(message)
|
||||
|
||||
# Add function call items
|
||||
items.extend(function_calls)
|
||||
|
||||
# Add function result items
|
||||
items.extend(function_results)
|
||||
|
||||
# Apply pagination
|
||||
if order == "desc":
|
||||
items = items[::-1]
|
||||
|
||||
start_idx = 0
|
||||
if after:
|
||||
# Find the index after the cursor
|
||||
for i, item in enumerate(items):
|
||||
if item.id == after:
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
paginated_items = items[start_idx : start_idx + limit]
|
||||
has_more = len(items) > start_idx + limit
|
||||
|
||||
return paginated_items, has_more
|
||||
|
||||
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
|
||||
"""Get specific conversation item - O(1) lookup via index."""
|
||||
# Use index for O(1) lookup instead of linear search
|
||||
conv_items = self._item_index.get(conversation_id)
|
||||
if not conv_items:
|
||||
return None
|
||||
|
||||
return conv_items.get(item_id)
|
||||
|
||||
def get_thread(self, conversation_id: str) -> AgentThread | None:
|
||||
"""Get AgentThread for execution - CRITICAL for agent.run_stream()."""
|
||||
conv_data = self._conversations.get(conversation_id)
|
||||
return conv_data["thread"] if conv_data else None
|
||||
|
||||
def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
|
||||
"""Filter conversations by metadata (e.g., agent_id)."""
|
||||
results = []
|
||||
for conv_data in self._conversations.values():
|
||||
conv_meta = conv_data.get("metadata", {})
|
||||
# Check if all filter items match
|
||||
if all(conv_meta.get(k) == v for k, v in metadata_filter.items()):
|
||||
results.append(
|
||||
Conversation(
|
||||
id=conv_data["id"],
|
||||
object="conversation",
|
||||
created_at=conv_data["created_at"],
|
||||
metadata=conv_meta,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -20,6 +20,10 @@ from .models._discovery_models import EntityInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants for remote entity fetching
|
||||
REMOTE_FETCH_TIMEOUT_SECONDS = 30.0
|
||||
REMOTE_FETCH_MAX_SIZE_MB = 10
|
||||
|
||||
|
||||
class EntityDiscovery:
|
||||
"""Discovery for Agent Framework entities - agents and workflows."""
|
||||
@@ -116,16 +120,9 @@ class EntityDiscovery:
|
||||
# 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}"
|
||||
# In-memory entities: use class name as it's more readable than UUID
|
||||
class_name = entity_object.__class__.__name__
|
||||
name = f"{entity_type.title()} {class_name}"
|
||||
description = getattr(entity_object, "description", "")
|
||||
|
||||
# Generate entity ID using Agent Framework specific naming
|
||||
@@ -142,43 +139,27 @@ class EntityDiscovery:
|
||||
middleware_list = None
|
||||
|
||||
if entity_type == "agent":
|
||||
# Try to get instructions
|
||||
if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
|
||||
instructions = entity_object.chat_options.instructions
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
# Try to get model - check both chat_options and chat_client
|
||||
if (
|
||||
hasattr(entity_object, "chat_options")
|
||||
and hasattr(entity_object.chat_options, "model_id")
|
||||
and entity_object.chat_options.model_id
|
||||
):
|
||||
model = entity_object.chat_options.model_id
|
||||
elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
|
||||
model = entity_object.chat_client.model_id
|
||||
agent_meta = extract_agent_metadata(entity_object)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_providers_list = agent_meta["context_providers"]
|
||||
middleware_list = agent_meta["middleware"]
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(entity_object, "chat_client"):
|
||||
chat_client_type = entity_object.chat_client.__class__.__name__
|
||||
# Log helpful info about agent capabilities (before creating EntityInfo)
|
||||
if entity_type == "agent":
|
||||
has_run_stream = hasattr(entity_object, "run_stream")
|
||||
has_run = hasattr(entity_object, "run")
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(entity_object, "context_provider")
|
||||
and entity_object.context_provider
|
||||
and hasattr(entity_object.context_provider, "__class__")
|
||||
):
|
||||
context_providers_list = [entity_object.context_provider.__class__.__name__]
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(entity_object, "middleware") and entity_object.middleware:
|
||||
middleware_list = []
|
||||
for m in entity_object.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middleware_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middleware_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middleware_list.append(str(m))
|
||||
if not has_run_stream and has_run:
|
||||
logger.info(
|
||||
f"Agent '{entity_id}' only has run() (non-streaming). "
|
||||
"DevUI will automatically convert to streaming."
|
||||
)
|
||||
elif not has_run_stream and not has_run:
|
||||
logger.warning(f"Agent '{entity_id}' lacks both run() and run_stream() methods. May not work.")
|
||||
|
||||
# Create EntityInfo with Agent Framework specifics
|
||||
return EntityInfo(
|
||||
@@ -444,7 +425,9 @@ class EntityDiscovery:
|
||||
pass
|
||||
|
||||
# Fallback to duck typing for agent protocol
|
||||
if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"):
|
||||
# Agent must have either run_stream() or run() method, plus id and name
|
||||
has_execution_method = hasattr(obj, "run_stream") or hasattr(obj, "run")
|
||||
if has_execution_method and hasattr(obj, "id") and hasattr(obj, "name"):
|
||||
return True
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
@@ -482,13 +465,9 @@ class EntityDiscovery:
|
||||
# Extract metadata from the live object with improved fallback naming
|
||||
name = getattr(obj, "name", None)
|
||||
if not name:
|
||||
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:
|
||||
name = f"{obj_type.title()} {obj.__class__.__name__}"
|
||||
# Use class name as it's more readable than UUID
|
||||
class_name = obj.__class__.__name__
|
||||
name = f"{obj_type.title()} {class_name}"
|
||||
description = getattr(obj, "description", None)
|
||||
tools = await self._extract_tools_from_object(obj, obj_type)
|
||||
|
||||
@@ -505,39 +484,14 @@ class EntityDiscovery:
|
||||
middleware_list = None
|
||||
|
||||
if obj_type == "agent":
|
||||
# Try to get instructions
|
||||
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "instructions"):
|
||||
instructions = obj.chat_options.instructions
|
||||
from ._utils import extract_agent_metadata
|
||||
|
||||
# Try to get model - check both chat_options and chat_client
|
||||
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "model_id") and obj.chat_options.model_id:
|
||||
model = obj.chat_options.model_id
|
||||
elif hasattr(obj, "chat_client") and hasattr(obj.chat_client, "model_id"):
|
||||
model = obj.chat_client.model_id
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(obj, "chat_client"):
|
||||
chat_client_type = obj.chat_client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(obj, "context_provider")
|
||||
and obj.context_provider
|
||||
and hasattr(obj.context_provider, "__class__")
|
||||
):
|
||||
context_providers_list = [obj.context_provider.__class__.__name__]
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(obj, "middleware") and obj.middleware:
|
||||
middleware_list = []
|
||||
for m in obj.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middleware_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middleware_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middleware_list.append(str(m))
|
||||
agent_meta = extract_agent_metadata(obj)
|
||||
instructions = agent_meta["instructions"]
|
||||
model = agent_meta["model"]
|
||||
chat_client_type = agent_meta["chat_client_type"]
|
||||
context_providers_list = agent_meta["context_providers"]
|
||||
middleware_list = agent_meta["middleware"]
|
||||
|
||||
entity_info = EntityInfo(
|
||||
id=entity_id,
|
||||
@@ -628,7 +582,7 @@ class EntityDiscovery:
|
||||
source: Source of entity (directory, in_memory, remote)
|
||||
|
||||
Returns:
|
||||
Unique entity ID with format: {type}_{source}_{name}_{uuid8}
|
||||
Unique entity ID with format: {type}_{source}_{name}_{uuid}
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -644,10 +598,10 @@ class EntityDiscovery:
|
||||
else:
|
||||
base_name = "entity"
|
||||
|
||||
# Generate short UUID (8 chars = 4 billion combinations)
|
||||
short_uuid = uuid.uuid4().hex[:8]
|
||||
# Generate full UUID for guaranteed uniqueness
|
||||
full_uuid = uuid.uuid4().hex
|
||||
|
||||
return f"{entity_type}_{source}_{base_name}_{short_uuid}"
|
||||
return f"{entity_type}_{source}_{base_name}_{full_uuid}"
|
||||
|
||||
async def fetch_remote_entity(
|
||||
self, url: str, metadata: dict[str, Any] | None = None
|
||||
@@ -722,12 +676,10 @@ class EntityDiscovery:
|
||||
|
||||
return url
|
||||
|
||||
async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None:
|
||||
async def _fetch_url_content(self, url: str, max_size_mb: int = REMOTE_FETCH_MAX_SIZE_MB) -> 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:
|
||||
async with httpx.AsyncClient(timeout=REMOTE_FETCH_TIMEOUT_SECONDS) as client:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, get_origin
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentThread
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
from ._conversations import ConversationStore, InMemoryConversationStore
|
||||
from ._discovery import EntityDiscovery
|
||||
from ._mapper import MessageMapper
|
||||
from ._tracing import capture_traces
|
||||
@@ -29,21 +29,26 @@ class EntityNotFoundError(Exception):
|
||||
class AgentFrameworkExecutor:
|
||||
"""Executor for Agent Framework entities - agents and workflows."""
|
||||
|
||||
def __init__(self, entity_discovery: EntityDiscovery, message_mapper: MessageMapper):
|
||||
def __init__(
|
||||
self,
|
||||
entity_discovery: EntityDiscovery,
|
||||
message_mapper: MessageMapper,
|
||||
conversation_store: ConversationStore | None = None,
|
||||
):
|
||||
"""Initialize Agent Framework executor.
|
||||
|
||||
Args:
|
||||
entity_discovery: Entity discovery instance
|
||||
message_mapper: Message mapper instance
|
||||
conversation_store: Optional conversation store (defaults to in-memory)
|
||||
"""
|
||||
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
|
||||
# Use provided conversation store or default to in-memory
|
||||
self.conversation_store = conversation_store or InMemoryConversationStore()
|
||||
|
||||
def _setup_tracing_provider(self) -> None:
|
||||
"""Set up our own TracerProvider so we can add processors."""
|
||||
@@ -83,199 +88,6 @@ class AgentFrameworkExecutor:
|
||||
else:
|
||||
logger.debug("ENABLE_OTEL not set, skipping observability 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
|
||||
|
||||
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, preserving all content types 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 messages
|
||||
if role not in ["user", "assistant"]:
|
||||
continue
|
||||
|
||||
# Extract all user-facing content (text, images, files, etc.)
|
||||
display_contents = self._extract_display_contents(af_msg.contents)
|
||||
|
||||
# Skip messages with no displayable content
|
||||
if not display_contents:
|
||||
continue
|
||||
|
||||
# Extract usage information if present
|
||||
usage_data = None
|
||||
for content in af_msg.contents:
|
||||
content_type = getattr(content, "type", None)
|
||||
if content_type == "usage":
|
||||
details = getattr(content, "details", None)
|
||||
if details:
|
||||
usage_data = {
|
||||
"total_tokens": getattr(details, "total_token_count", 0) or 0,
|
||||
"prompt_tokens": getattr(details, "input_token_count", 0) or 0,
|
||||
"completion_tokens": getattr(details, "output_token_count", 0) or 0,
|
||||
}
|
||||
break
|
||||
|
||||
ui_message = {
|
||||
"id": af_msg.message_id or f"restored-{i}",
|
||||
"role": role,
|
||||
"contents": display_contents,
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||
"author_name": af_msg.author_name,
|
||||
"message_id": af_msg.message_id,
|
||||
}
|
||||
|
||||
# Add usage data if available
|
||||
if usage_data:
|
||||
ui_message["usage"] = usage_data
|
||||
|
||||
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_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)
|
||||
|
||||
# Text content
|
||||
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":
|
||||
display_contents.append({"type": "text", "text": sub_content.get("text", "")})
|
||||
except Exception:
|
||||
display_contents.append({"type": "text", "text": text})
|
||||
else:
|
||||
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 display_contents
|
||||
|
||||
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:
|
||||
thread = await AgentThread.deserialize(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.
|
||||
|
||||
@@ -390,7 +202,7 @@ class AgentFrameworkExecutor:
|
||||
yield {"type": "error", "message": str(e), "entity_id": entity_id}
|
||||
|
||||
async def _execute_agent(
|
||||
self, agent: Any, request: AgentFrameworkRequest, trace_collector: Any
|
||||
self, agent: AgentProtocol, request: AgentFrameworkRequest, trace_collector: Any
|
||||
) -> AsyncGenerator[Any, None]:
|
||||
"""Execute Agent Framework agent with trace collection and optional thread support.
|
||||
|
||||
@@ -406,34 +218,51 @@ class AgentFrameworkExecutor:
|
||||
# Convert input to proper ChatMessage or string
|
||||
user_message = self._convert_input_to_chat_message(request.input)
|
||||
|
||||
# Get thread if provided in extra_body
|
||||
# Get thread from conversation parameter (OpenAI standard!)
|
||||
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)
|
||||
conversation_id = request.get_conversation_id()
|
||||
if conversation_id:
|
||||
thread = self.conversation_store.get_thread(conversation_id)
|
||||
if thread:
|
||||
logger.debug(f"Using existing thread: {thread_id}")
|
||||
logger.debug(f"Using existing conversation: {conversation_id}")
|
||||
else:
|
||||
logger.warning(f"Thread {thread_id} not found, proceeding without thread")
|
||||
logger.warning(f"Conversation {conversation_id} not found, proceeding without thread")
|
||||
|
||||
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)}")
|
||||
# Check if agent supports streaming
|
||||
if hasattr(agent, "run_stream") and callable(agent.run_stream):
|
||||
# Use Agent Framework's native streaming with optional thread
|
||||
if thread:
|
||||
async for update in agent.run_stream(user_message, thread=thread):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
# Use Agent Framework's native streaming with optional thread
|
||||
if thread:
|
||||
async for update in agent.run_stream(user_message, thread=thread):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
yield update
|
||||
else:
|
||||
async for update in agent.run_stream(user_message):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
yield update
|
||||
yield update
|
||||
elif hasattr(agent, "run") and callable(agent.run):
|
||||
# Non-streaming agent - use run() and yield complete response
|
||||
logger.info("Agent lacks run_stream(), using run() method (non-streaming)")
|
||||
if thread:
|
||||
response = await agent.run(user_message, thread=thread)
|
||||
else:
|
||||
response = await agent.run(user_message)
|
||||
|
||||
# Yield trace events before response
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
# Yield the complete response (mapper will convert to streaming events)
|
||||
yield response
|
||||
else:
|
||||
async for update in agent.run_stream(user_message):
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
|
||||
yield update
|
||||
raise ValueError("Agent must implement either run() or run_stream() method")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in agent execution: {e}")
|
||||
@@ -455,8 +284,8 @@ class AgentFrameworkExecutor:
|
||||
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
|
||||
if request.extra_body and isinstance(request.extra_body, dict) and request.extra_body.get("input_data"):
|
||||
input_data = request.extra_body.get("input_data") # type: ignore
|
||||
logger.debug(f"Using structured input_data from extra_body: {type(input_data)}")
|
||||
else:
|
||||
input_data = request.input
|
||||
@@ -483,6 +312,9 @@ class AgentFrameworkExecutor:
|
||||
def _convert_input_to_chat_message(self, input_data: Any) -> Any:
|
||||
"""Convert OpenAI Responses API input to Agent Framework ChatMessage or string.
|
||||
|
||||
Handles various input formats including text, images, files, and multimodal content.
|
||||
Falls back to string extraction for simple cases.
|
||||
|
||||
Args:
|
||||
input_data: OpenAI ResponseInputParam (List[ResponseInputItemParam])
|
||||
|
||||
@@ -512,6 +344,9 @@ class AgentFrameworkExecutor:
|
||||
) -> Any:
|
||||
"""Convert OpenAI ResponseInputParam to Agent Framework ChatMessage.
|
||||
|
||||
Processes text, images, files, and other content types from OpenAI format
|
||||
to Agent Framework ChatMessage with appropriate content objects.
|
||||
|
||||
Args:
|
||||
input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects)
|
||||
ChatMessage: ChatMessage class for creating chat messages
|
||||
@@ -597,6 +432,40 @@ class AgentFrameworkExecutor:
|
||||
elif file_url:
|
||||
contents.append(DataContent(uri=file_url, media_type=media_type))
|
||||
|
||||
elif content_type == "function_approval_response":
|
||||
# Handle function approval response (DevUI extension)
|
||||
try:
|
||||
from agent_framework import FunctionApprovalResponseContent, FunctionCallContent
|
||||
|
||||
request_id = content_item.get("request_id", "")
|
||||
approved = content_item.get("approved", False)
|
||||
function_call_data = content_item.get("function_call", {})
|
||||
|
||||
# Create FunctionCallContent from the function_call data
|
||||
function_call = FunctionCallContent(
|
||||
call_id=function_call_data.get("id", ""),
|
||||
name=function_call_data.get("name", ""),
|
||||
arguments=function_call_data.get("arguments", {}),
|
||||
)
|
||||
|
||||
# Create FunctionApprovalResponseContent with correct signature
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approved, # positional argument
|
||||
id=request_id, # keyword argument 'id', NOT 'request_id'
|
||||
function_call=function_call, # FunctionCallContent object
|
||||
)
|
||||
contents.append(approval_response)
|
||||
logger.info(
|
||||
f"Added FunctionApprovalResponseContent: id={request_id}, "
|
||||
f"approved={approved}, call_id={function_call.call_id}"
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"FunctionApprovalResponseContent not available in agent_framework"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create FunctionApprovalResponseContent: {e}")
|
||||
|
||||
# Handle other OpenAI input item types as needed
|
||||
# (tool calls, function results, etc.)
|
||||
|
||||
@@ -687,23 +556,6 @@ class AgentFrameworkExecutor:
|
||||
|
||||
return start_executor, message_types
|
||||
|
||||
def _select_primary_input_type(self, message_types: list[Any]) -> Any | None:
|
||||
"""Choose the most user-friendly input type for workflow kick-off."""
|
||||
if not message_types:
|
||||
return None
|
||||
|
||||
preferred = (str, dict)
|
||||
|
||||
for candidate in preferred:
|
||||
for message_type in message_types:
|
||||
if message_type is candidate:
|
||||
return candidate
|
||||
origin = get_origin(message_type)
|
||||
if origin is candidate:
|
||||
return candidate
|
||||
|
||||
return message_types[0]
|
||||
|
||||
def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any:
|
||||
"""Parse structured input data for workflow execution.
|
||||
|
||||
@@ -728,7 +580,9 @@ class AgentFrameworkExecutor:
|
||||
return input_data
|
||||
|
||||
# Get the first (primary) input type
|
||||
input_type = self._select_primary_input_type(message_types)
|
||||
from ._utils import select_primary_input_type
|
||||
|
||||
input_type = select_primary_input_type(message_types)
|
||||
if input_type is None:
|
||||
logger.debug("Could not select primary input type for workflow - using raw dict")
|
||||
return input_data
|
||||
@@ -764,7 +618,9 @@ class AgentFrameworkExecutor:
|
||||
return raw_input
|
||||
|
||||
# Get the first (primary) input type
|
||||
input_type = self._select_primary_input_type(message_types)
|
||||
from ._utils import select_primary_input_type
|
||||
|
||||
input_type = select_primary_input_type(message_types)
|
||||
if input_type is None:
|
||||
logger.debug("Could not select primary input type for workflow - using raw string")
|
||||
return raw_input
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
@@ -17,6 +18,8 @@ from .models import (
|
||||
ResponseErrorEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseReasoningTextDeltaEvent,
|
||||
@@ -24,7 +27,6 @@ from .models import (
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseUsage,
|
||||
ResponseUsageEventComplete,
|
||||
ResponseWorkflowEventComplete,
|
||||
)
|
||||
|
||||
@@ -34,19 +36,26 @@ logger = logging.getLogger(__name__)
|
||||
EventType = Union[
|
||||
ResponseStreamEvent,
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseUsageEventComplete,
|
||||
]
|
||||
|
||||
|
||||
class MessageMapper:
|
||||
"""Maps Agent Framework messages/responses to OpenAI format."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Agent Framework message mapper."""
|
||||
def __init__(self, max_contexts: int = 1000) -> None:
|
||||
"""Initialize Agent Framework message mapper.
|
||||
|
||||
Args:
|
||||
max_contexts: Maximum number of contexts to keep in memory (default: 1000)
|
||||
"""
|
||||
self.sequence_counter = 0
|
||||
self._conversion_contexts: dict[int, dict[str, Any]] = {}
|
||||
self._conversion_contexts: OrderedDict[int, dict[str, Any]] = OrderedDict()
|
||||
self._max_contexts = max_contexts
|
||||
|
||||
# Track usage per request for final Response.usage (OpenAI standard)
|
||||
self._usage_accumulator: dict[str, dict[str, int]] = {}
|
||||
|
||||
# Register content type mappers for all 12 Agent Framework content types
|
||||
self.content_mappers = {
|
||||
@@ -95,7 +104,7 @@ class MessageMapper:
|
||||
|
||||
# Import Agent Framework types for proper isinstance checks
|
||||
try:
|
||||
from agent_framework import AgentRunResponseUpdate, WorkflowEvent
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, WorkflowEvent
|
||||
from agent_framework._workflows._events import AgentRunUpdateEvent
|
||||
|
||||
# Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate
|
||||
@@ -107,6 +116,10 @@ class MessageMapper:
|
||||
# If no data, treat as generic workflow event
|
||||
return await self._convert_workflow_event(raw_event, context)
|
||||
|
||||
# Handle complete agent response (AgentRunResponse) - for non-streaming agent execution
|
||||
if isinstance(raw_event, AgentRunResponse):
|
||||
return await self._convert_agent_response(raw_event, context)
|
||||
|
||||
# Handle agent updates (AgentRunResponseUpdate) - for direct agent execution
|
||||
if isinstance(raw_event, AgentRunResponseUpdate):
|
||||
return await self._convert_agent_update(raw_event, context)
|
||||
@@ -159,17 +172,31 @@ class MessageMapper:
|
||||
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
|
||||
# Get usage from accumulator (OpenAI standard)
|
||||
request_id = str(id(request))
|
||||
usage_data = self._usage_accumulator.get(request_id)
|
||||
|
||||
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),
|
||||
)
|
||||
if usage_data:
|
||||
usage = ResponseUsage(
|
||||
input_tokens=usage_data["input_tokens"],
|
||||
output_tokens=usage_data["output_tokens"],
|
||||
total_tokens=usage_data["total_tokens"],
|
||||
input_tokens_details=InputTokensDetails(cached_tokens=0),
|
||||
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
|
||||
)
|
||||
# Cleanup accumulator
|
||||
del self._usage_accumulator[request_id]
|
||||
else:
|
||||
# Fallback: estimate if no usage was tracked
|
||||
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]}",
|
||||
@@ -186,10 +213,18 @@ class MessageMapper:
|
||||
except Exception as e:
|
||||
logger.exception(f"Error aggregating response: {e}")
|
||||
return await self._create_error_response(str(e), request)
|
||||
finally:
|
||||
# Cleanup: Remove context after aggregation to prevent memory leak
|
||||
# This handles the common case where streaming completes successfully
|
||||
request_key = id(request)
|
||||
if self._conversion_contexts.pop(request_key, None):
|
||||
logger.debug(f"Cleaned up context for request {request_key} after aggregation")
|
||||
|
||||
def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]:
|
||||
"""Get or create conversion context for this request.
|
||||
|
||||
Uses LRU eviction when max_contexts is reached to prevent unbounded memory growth.
|
||||
|
||||
Args:
|
||||
request: Request to get context for
|
||||
|
||||
@@ -197,13 +232,26 @@ class MessageMapper:
|
||||
Conversion context dictionary
|
||||
"""
|
||||
request_key = id(request)
|
||||
|
||||
if request_key not in self._conversion_contexts:
|
||||
# Evict oldest context if at capacity (LRU eviction)
|
||||
if len(self._conversion_contexts) >= self._max_contexts:
|
||||
evicted_key, _ = self._conversion_contexts.popitem(last=False)
|
||||
logger.debug(f"Evicted oldest context (key={evicted_key}) - at max capacity ({self._max_contexts})")
|
||||
|
||||
self._conversion_contexts[request_key] = {
|
||||
"sequence_counter": 0,
|
||||
"item_id": f"msg_{uuid.uuid4().hex[:8]}",
|
||||
"content_index": 0,
|
||||
"output_index": 0,
|
||||
"request_id": str(request_key), # For usage accumulation
|
||||
# Track active function calls: {call_id: {name, item_id, args_chunks}}
|
||||
"active_function_calls": {},
|
||||
}
|
||||
else:
|
||||
# Move to end (mark as recently used for LRU)
|
||||
self._conversion_contexts.move_to_end(request_key)
|
||||
|
||||
return self._conversion_contexts[request_key]
|
||||
|
||||
def _next_sequence(self, context: dict[str, Any]) -> int:
|
||||
@@ -240,10 +288,11 @@ class MessageMapper:
|
||||
|
||||
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)
|
||||
if mapped_events is not None: # Handle None returns (e.g., UsageContent)
|
||||
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))
|
||||
@@ -256,6 +305,59 @@ class MessageMapper:
|
||||
|
||||
return events
|
||||
|
||||
async def _convert_agent_response(self, response: Any, context: dict[str, Any]) -> Sequence[Any]:
|
||||
"""Convert complete AgentRunResponse to OpenAI events.
|
||||
|
||||
This handles non-streaming agent execution where agent.run() returns
|
||||
a complete AgentRunResponse instead of streaming AgentRunResponseUpdate objects.
|
||||
|
||||
Args:
|
||||
response: Agent run response (AgentRunResponse)
|
||||
context: Conversion context
|
||||
|
||||
Returns:
|
||||
List of OpenAI response stream events
|
||||
"""
|
||||
events: list[Any] = []
|
||||
|
||||
try:
|
||||
# Extract all messages from the response
|
||||
messages = getattr(response, "messages", [])
|
||||
|
||||
# Convert each message's contents to streaming events
|
||||
for message in messages:
|
||||
if hasattr(message, "contents") and message.contents:
|
||||
for content in message.contents:
|
||||
content_type = content.__class__.__name__
|
||||
|
||||
if content_type in self.content_mappers:
|
||||
mapped_events = await self.content_mappers[content_type](content, context)
|
||||
if mapped_events is not None: # Handle None returns (e.g., UsageContent)
|
||||
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
|
||||
|
||||
# Add usage information if present
|
||||
usage_details = getattr(response, "usage_details", None)
|
||||
if usage_details:
|
||||
from agent_framework import UsageContent
|
||||
|
||||
usage_content = UsageContent(details=usage_details)
|
||||
await self._map_usage_content(usage_content, context)
|
||||
# Note: _map_usage_content returns None - it accumulates usage for final Response.usage
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error converting agent response: {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.
|
||||
|
||||
@@ -317,41 +419,143 @@ class MessageMapper:
|
||||
|
||||
async def _map_function_call_content(
|
||||
self, content: Any, context: dict[str, Any]
|
||||
) -> list[ResponseFunctionCallArgumentsDeltaEvent]:
|
||||
"""Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s)."""
|
||||
events = []
|
||||
) -> list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent]:
|
||||
"""Map FunctionCallContent to OpenAI events following Responses API spec.
|
||||
|
||||
# For streaming, need to chunk the arguments JSON
|
||||
args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}"
|
||||
Agent Framework emits FunctionCallContent in two patterns:
|
||||
1. First event: call_id + name + empty/no arguments
|
||||
2. Subsequent events: empty call_id/name + argument chunks
|
||||
|
||||
# Chunk the JSON string for streaming
|
||||
for chunk in self._chunk_json_string(args_str):
|
||||
We emit:
|
||||
1. response.output_item.added (with full metadata) for the first event
|
||||
2. response.function_call_arguments.delta (referencing item_id) for chunks
|
||||
"""
|
||||
events: list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent] = []
|
||||
|
||||
# CASE 1: New function call (has call_id and name)
|
||||
# This is the first event that establishes the function call
|
||||
if content.call_id and content.name:
|
||||
# Use call_id as item_id (simpler, and call_id uniquely identifies the call)
|
||||
item_id = content.call_id
|
||||
|
||||
# Track this function call for later argument deltas
|
||||
context["active_function_calls"][content.call_id] = {
|
||||
"item_id": item_id,
|
||||
"name": content.name,
|
||||
"arguments_chunks": [],
|
||||
}
|
||||
|
||||
logger.debug(f"New function call: {content.name} (call_id={content.call_id})")
|
||||
|
||||
# Emit response.output_item.added event per OpenAI spec
|
||||
events.append(
|
||||
ResponseFunctionCallArgumentsDeltaEvent(
|
||||
type="response.function_call_arguments.delta",
|
||||
delta=chunk,
|
||||
item_id=context["item_id"],
|
||||
ResponseOutputItemAddedEvent(
|
||||
type="response.output_item.added",
|
||||
item=ResponseFunctionToolCall(
|
||||
id=content.call_id, # Use call_id as the item id
|
||||
call_id=content.call_id,
|
||||
name=content.name,
|
||||
arguments="", # Empty initially, will be filled by deltas
|
||||
type="function_call",
|
||||
status="in_progress",
|
||||
),
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
)
|
||||
|
||||
# CASE 2: Argument deltas (content has arguments, possibly without call_id/name)
|
||||
if content.arguments:
|
||||
# Find the active function call for these arguments
|
||||
active_call = self._get_active_function_call(content, context)
|
||||
|
||||
if active_call:
|
||||
item_id = active_call["item_id"]
|
||||
|
||||
# Convert arguments to string if it's a dict (Agent Framework may send either)
|
||||
delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments)
|
||||
|
||||
# Emit argument delta referencing the item_id
|
||||
events.append(
|
||||
ResponseFunctionCallArgumentsDeltaEvent(
|
||||
type="response.function_call_arguments.delta",
|
||||
delta=delta_str,
|
||||
item_id=item_id,
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
)
|
||||
|
||||
# Track chunk for debugging
|
||||
active_call["arguments_chunks"].append(delta_str)
|
||||
else:
|
||||
logger.warning(f"Received function call arguments without active call: {content.arguments[:50]}...")
|
||||
|
||||
return events
|
||||
|
||||
def _get_active_function_call(self, content: Any, context: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Find the active function call for this content.
|
||||
|
||||
Uses call_id if present, otherwise falls back to most recent call.
|
||||
Necessary because Agent Framework may send argument chunks without call_id.
|
||||
|
||||
Args:
|
||||
content: FunctionCallContent with possible call_id
|
||||
context: Conversion context with active_function_calls
|
||||
|
||||
Returns:
|
||||
Active call dict or None
|
||||
"""
|
||||
active_calls: dict[str, dict[str, Any]] = context["active_function_calls"]
|
||||
|
||||
# If content has call_id, use it to find the exact call
|
||||
if hasattr(content, "call_id") and content.call_id:
|
||||
result = active_calls.get(content.call_id)
|
||||
return result if result is not None else None
|
||||
|
||||
# Otherwise, use the most recent call (last one added)
|
||||
# This handles the case where Agent Framework sends argument chunks
|
||||
# without call_id in subsequent events
|
||||
if active_calls:
|
||||
return list(active_calls.values())[-1]
|
||||
|
||||
return None
|
||||
|
||||
async def _map_function_result_content(
|
||||
self, content: Any, context: dict[str, Any]
|
||||
) -> ResponseFunctionResultComplete:
|
||||
"""Map FunctionResultContent to structured event."""
|
||||
"""Map FunctionResultContent to custom DevUI event.
|
||||
|
||||
This is a DevUI extension - OpenAI doesn't stream function execution results
|
||||
because in their model, applications execute functions, not the API.
|
||||
Agent Framework executes functions, so we emit this event for debugging visibility.
|
||||
|
||||
IMPORTANT: Always use Agent Framework's call_id from the content.
|
||||
Do NOT generate a new call_id - it must match the one from the function call event.
|
||||
"""
|
||||
# Get call_id from content - this MUST match the call_id from the function call
|
||||
call_id = getattr(content, "call_id", None)
|
||||
|
||||
if not call_id:
|
||||
logger.warning("FunctionResultContent missing call_id - this will break call/result pairing")
|
||||
call_id = f"call_{uuid.uuid4().hex[:8]}" # Fallback only if truly missing
|
||||
|
||||
# Extract result
|
||||
result = getattr(content, "result", None)
|
||||
exception = getattr(content, "exception", None)
|
||||
|
||||
# Convert result to string
|
||||
output = result if isinstance(result, str) else json.dumps(result) if result is not None else ""
|
||||
|
||||
# Determine status
|
||||
status = "incomplete" if exception else "completed"
|
||||
|
||||
# Return custom DevUI 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]}"),
|
||||
call_id=call_id,
|
||||
output=output,
|
||||
status=status,
|
||||
item_id=context["item_id"],
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
@@ -367,37 +571,34 @@ class MessageMapper:
|
||||
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)
|
||||
async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> None:
|
||||
"""Accumulate usage data for final Response.usage field.
|
||||
|
||||
OpenAI does NOT stream usage events. Usage appears only in final Response.
|
||||
This method accumulates usage data per request for later inclusion in Response.usage.
|
||||
|
||||
Returns:
|
||||
None - no event emitted (usage goes in final Response.usage)
|
||||
"""
|
||||
# Extract usage from UsageContent.details (UsageDetails object)
|
||||
details = getattr(content, "details", None)
|
||||
total_tokens = 0
|
||||
prompt_tokens = 0
|
||||
completion_tokens = 0
|
||||
total_tokens = getattr(details, "total_token_count", 0) or 0
|
||||
prompt_tokens = getattr(details, "input_token_count", 0) or 0
|
||||
completion_tokens = getattr(details, "output_token_count", 0) or 0
|
||||
|
||||
if details:
|
||||
total_tokens = getattr(details, "total_token_count", 0) or 0
|
||||
prompt_tokens = getattr(details, "input_token_count", 0) or 0
|
||||
completion_tokens = getattr(details, "output_token_count", 0) or 0
|
||||
# Accumulate for final Response.usage
|
||||
request_id = context.get("request_id", "default")
|
||||
if request_id not in self._usage_accumulator:
|
||||
self._usage_accumulator[request_id] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
return ResponseUsageEventComplete(
|
||||
type="response.usage.complete",
|
||||
data={
|
||||
"usage_data": details.to_dict() if details and hasattr(details, "to_dict") else {},
|
||||
"total_tokens": total_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
item_id=context["item_id"],
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
)
|
||||
self._usage_accumulator[request_id]["input_tokens"] += prompt_tokens
|
||||
self._usage_accumulator[request_id]["output_tokens"] += completion_tokens
|
||||
self._usage_accumulator[request_id]["total_tokens"] += total_tokens
|
||||
|
||||
logger.debug(f"Accumulated usage for {request_id}: {self._usage_accumulator[request_id]}")
|
||||
|
||||
# NO EVENT RETURNED - usage goes in final Response only
|
||||
return
|
||||
|
||||
async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
|
||||
"""Map DataContent to structured trace event."""
|
||||
@@ -510,19 +711,15 @@ class MessageMapper:
|
||||
|
||||
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"
|
||||
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"
|
||||
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}"
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, get_origin
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -23,47 +23,6 @@ from .models._discovery_models import DiscoveryResponse, EntityInfo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_executor_message_types(executor: Any) -> list[Any]:
|
||||
"""Return declared input types for the given executor."""
|
||||
message_types: list[Any] = []
|
||||
|
||||
try:
|
||||
input_types = getattr(executor, "input_types", None)
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to access executor input_types: {exc}")
|
||||
else:
|
||||
if input_types:
|
||||
message_types = list(input_types)
|
||||
|
||||
if not message_types and hasattr(executor, "_handlers"):
|
||||
try:
|
||||
handlers = executor._handlers
|
||||
if isinstance(handlers, dict):
|
||||
message_types = list(handlers.keys())
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to read executor handlers: {exc}")
|
||||
|
||||
return message_types
|
||||
|
||||
|
||||
def _select_primary_input_type(message_types: list[Any]) -> Any | None:
|
||||
"""Choose the most user-friendly input type for rendering workflow inputs."""
|
||||
if not message_types:
|
||||
return None
|
||||
|
||||
preferred = (str, dict)
|
||||
|
||||
for candidate in preferred:
|
||||
for message_type in message_types:
|
||||
if message_type is candidate:
|
||||
return candidate
|
||||
origin = get_origin(message_type)
|
||||
if origin is candidate:
|
||||
return candidate
|
||||
|
||||
return message_types[0]
|
||||
|
||||
|
||||
class DevServer:
|
||||
"""Development Server - OpenAI compatible API server for debugging agents."""
|
||||
|
||||
@@ -263,7 +222,11 @@ class DevServer:
|
||||
start_executor_id = ""
|
||||
|
||||
try:
|
||||
from ._utils import generate_input_schema
|
||||
from ._utils import (
|
||||
extract_executor_message_types,
|
||||
generate_input_schema,
|
||||
select_primary_input_type,
|
||||
)
|
||||
|
||||
start_executor = entity_obj.get_start_executor()
|
||||
except Exception as e:
|
||||
@@ -274,8 +237,8 @@ class DevServer:
|
||||
start_executor, "id", ""
|
||||
)
|
||||
|
||||
message_types = _extract_executor_message_types(start_executor)
|
||||
input_type = _select_primary_input_type(message_types)
|
||||
message_types = extract_executor_message_types(start_executor)
|
||||
input_type = select_primary_input_type(message_types)
|
||||
|
||||
if input_type:
|
||||
input_type_name = getattr(input_type, "__name__", str(input_type))
|
||||
@@ -421,112 +384,161 @@ class DevServer:
|
||||
error = OpenAIError.create(f"Execution failed: {e!s}")
|
||||
return JSONResponse(status_code=500, content=error.to_dict())
|
||||
|
||||
@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")
|
||||
# ========================================
|
||||
# OpenAI Conversations API (Standard)
|
||||
# ========================================
|
||||
|
||||
@app.post("/v1/conversations")
|
||||
async def create_conversation(request_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new conversation - OpenAI standard."""
|
||||
try:
|
||||
metadata = request_data.get("metadata")
|
||||
executor = await self._ensure_executor()
|
||||
thread_id = executor.create_thread(agent_id)
|
||||
conversation = executor.conversation_store.create_conversation(metadata=metadata)
|
||||
return conversation.model_dump()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating conversation: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create conversation: {e!s}") from e
|
||||
|
||||
@app.get("/v1/conversations")
|
||||
async def list_conversations(agent_id: str | None = None) -> dict[str, Any]:
|
||||
"""List conversations, optionally filtered by agent_id."""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
|
||||
if agent_id:
|
||||
# Filter by agent_id metadata
|
||||
conversations = executor.conversation_store.list_conversations_by_metadata({"agent_id": agent_id})
|
||||
else:
|
||||
# Return all conversations (for InMemoryStore, list all)
|
||||
# Note: This assumes list_conversations_by_metadata({}) returns all
|
||||
conversations = executor.conversation_store.list_conversations_by_metadata({})
|
||||
|
||||
return {
|
||||
"id": thread_id,
|
||||
"object": "thread",
|
||||
"created_at": int(__import__("time").time()),
|
||||
"metadata": {"agent_id": agent_id},
|
||||
"object": "list",
|
||||
"data": [conv.model_dump() for conv in conversations],
|
||||
"has_more": False,
|
||||
}
|
||||
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
|
||||
logger.error(f"Error listing conversations: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list conversations: {e!s}") from e
|
||||
|
||||
@app.get("/v1/threads")
|
||||
async def list_threads(agent_id: str) -> dict[str, Any]:
|
||||
"""List threads for an agent."""
|
||||
@app.get("/v1/conversations/{conversation_id}")
|
||||
async def retrieve_conversation(conversation_id: str) -> dict[str, Any]:
|
||||
"""Get conversation - OpenAI standard."""
|
||||
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}
|
||||
conversation = executor.conversation_store.get_conversation(conversation_id)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return conversation.model_dump()
|
||||
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
|
||||
logger.error(f"Error getting conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get conversation: {e!s}") from e
|
||||
|
||||
@app.delete("/v1/threads/{thread_id}")
|
||||
async def delete_thread(thread_id: str) -> dict[str, Any]:
|
||||
"""Delete a thread."""
|
||||
@app.post("/v1/conversations/{conversation_id}")
|
||||
async def update_conversation(conversation_id: str, request_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update conversation metadata - OpenAI standard."""
|
||||
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}
|
||||
metadata = request_data.get("metadata", {})
|
||||
conversation = executor.conversation_store.update_conversation(conversation_id, metadata=metadata)
|
||||
return conversation.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
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
|
||||
logger.error(f"Error updating conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update conversation: {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."""
|
||||
@app.delete("/v1/conversations/{conversation_id}")
|
||||
async def delete_conversation(conversation_id: str) -> dict[str, Any]:
|
||||
"""Delete conversation - OpenAI standard."""
|
||||
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}
|
||||
result = executor.conversation_store.delete_conversation(conversation_id)
|
||||
return result.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
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
|
||||
logger.error(f"Error deleting conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete conversation: {e!s}") from e
|
||||
|
||||
@app.post("/v1/conversations/{conversation_id}/items")
|
||||
async def create_conversation_items(conversation_id: str, request_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Add items to conversation - OpenAI standard."""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
items = request_data.get("items", [])
|
||||
conv_items = await executor.conversation_store.add_items(conversation_id, items=items)
|
||||
return {"object": "list", "data": [item.model_dump() for item in conv_items]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding items to conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add items: {e!s}") from e
|
||||
|
||||
@app.get("/v1/conversations/{conversation_id}/items")
|
||||
async def list_conversation_items(
|
||||
conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
|
||||
) -> dict[str, Any]:
|
||||
"""List conversation items - OpenAI standard."""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
items, has_more = await executor.conversation_store.list_items(
|
||||
conversation_id, limit=limit, after=after, order=order
|
||||
)
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [item.model_dump() for item in items],
|
||||
"has_more": has_more,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing items for conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list items: {e!s}") from e
|
||||
|
||||
@app.get("/v1/conversations/{conversation_id}/items/{item_id}")
|
||||
async def retrieve_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]:
|
||||
"""Get specific conversation item - OpenAI standard."""
|
||||
try:
|
||||
executor = await self._ensure_executor()
|
||||
item = executor.conversation_store.get_item(conversation_id, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return item.model_dump()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting item {item_id} from conversation {conversation_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get item: {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
|
||||
# Collect events for final response.completed event
|
||||
events = []
|
||||
|
||||
# Stream all events
|
||||
async for event in executor.execute_streaming(request):
|
||||
events.append(event)
|
||||
|
||||
# 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"):
|
||||
@@ -544,6 +556,17 @@ class DevServer:
|
||||
payload = json.dumps(str(event))
|
||||
yield f"data: {payload}\n\n"
|
||||
|
||||
# Aggregate to final response and emit response.completed event (OpenAI standard)
|
||||
from .models import ResponseCompletedEvent
|
||||
|
||||
final_response = await executor.message_mapper.aggregate_to_response(events, request)
|
||||
completed_event = ResponseCompletedEvent(
|
||||
type="response.completed",
|
||||
response=final_response,
|
||||
sequence_number=len(events),
|
||||
)
|
||||
yield f"data: {completed_event.model_dump_json()}\n\n"
|
||||
|
||||
# Send final done event
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
@@ -10,6 +10,133 @@ from typing import Any, get_args, get_origin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Agent Metadata Extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
|
||||
"""Extract agent-specific metadata from an entity object.
|
||||
|
||||
Args:
|
||||
entity_object: Agent Framework agent object
|
||||
|
||||
Returns:
|
||||
Dictionary with agent metadata: instructions, model, chat_client_type,
|
||||
context_providers, and middleware
|
||||
"""
|
||||
metadata = {
|
||||
"instructions": None,
|
||||
"model": None,
|
||||
"chat_client_type": None,
|
||||
"context_providers": None,
|
||||
"middleware": None,
|
||||
}
|
||||
|
||||
# Try to get instructions
|
||||
if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
|
||||
metadata["instructions"] = entity_object.chat_options.instructions
|
||||
|
||||
# Try to get model - check both chat_options and chat_client
|
||||
if (
|
||||
hasattr(entity_object, "chat_options")
|
||||
and hasattr(entity_object.chat_options, "model_id")
|
||||
and entity_object.chat_options.model_id
|
||||
):
|
||||
metadata["model"] = entity_object.chat_options.model_id
|
||||
elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
|
||||
metadata["model"] = entity_object.chat_client.model_id
|
||||
|
||||
# Try to get chat client type
|
||||
if hasattr(entity_object, "chat_client"):
|
||||
metadata["chat_client_type"] = entity_object.chat_client.__class__.__name__
|
||||
|
||||
# Try to get context providers
|
||||
if (
|
||||
hasattr(entity_object, "context_provider")
|
||||
and entity_object.context_provider
|
||||
and hasattr(entity_object.context_provider, "__class__")
|
||||
):
|
||||
metadata["context_providers"] = [entity_object.context_provider.__class__.__name__] # type: ignore
|
||||
|
||||
# Try to get middleware
|
||||
if hasattr(entity_object, "middleware") and entity_object.middleware:
|
||||
middleware_list: list[str] = []
|
||||
for m in entity_object.middleware:
|
||||
# Try multiple ways to get a good name for middleware
|
||||
if hasattr(m, "__name__"): # Function or callable
|
||||
middleware_list.append(m.__name__)
|
||||
elif hasattr(m, "__class__"): # Class instance
|
||||
middleware_list.append(m.__class__.__name__)
|
||||
else:
|
||||
middleware_list.append(str(m))
|
||||
metadata["middleware"] = middleware_list # type: ignore
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Input Type Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_executor_message_types(executor: Any) -> list[Any]:
|
||||
"""Extract declared input types for the given executor.
|
||||
|
||||
Args:
|
||||
executor: Workflow executor object
|
||||
|
||||
Returns:
|
||||
List of message types that the executor accepts
|
||||
"""
|
||||
message_types: list[Any] = []
|
||||
|
||||
try:
|
||||
input_types = getattr(executor, "input_types", None)
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to access executor input_types: {exc}")
|
||||
else:
|
||||
if input_types:
|
||||
message_types = list(input_types)
|
||||
|
||||
if not message_types and hasattr(executor, "_handlers"):
|
||||
try:
|
||||
handlers = executor._handlers
|
||||
if isinstance(handlers, dict):
|
||||
message_types = list(handlers.keys())
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.debug(f"Failed to read executor handlers: {exc}")
|
||||
|
||||
return message_types
|
||||
|
||||
|
||||
def select_primary_input_type(message_types: list[Any]) -> Any | None:
|
||||
"""Choose the most user-friendly input type for workflow inputs.
|
||||
|
||||
Prefers str and dict types for better user experience.
|
||||
|
||||
Args:
|
||||
message_types: List of possible message types
|
||||
|
||||
Returns:
|
||||
Selected primary input type, or None if list is empty
|
||||
"""
|
||||
if not message_types:
|
||||
return None
|
||||
|
||||
preferred = (str, dict)
|
||||
|
||||
for candidate in preferred:
|
||||
for message_type in message_types:
|
||||
if message_type is candidate:
|
||||
return candidate
|
||||
origin = get_origin(message_type)
|
||||
if origin is candidate:
|
||||
return candidate
|
||||
|
||||
return message_types[0]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Type System Utilities
|
||||
# ============================================================================
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
|
||||
# Import discovery models
|
||||
# Import all OpenAI types directly from the openai package
|
||||
from openai.types.conversations import Conversation, ConversationDeletedResource
|
||||
from openai.types.conversations.conversation_item import ConversationItem
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseReasoningTextDeltaEvent,
|
||||
@@ -25,14 +32,9 @@ from ._openai_custom import (
|
||||
AgentFrameworkRequest,
|
||||
OpenAIError,
|
||||
ResponseFunctionResultComplete,
|
||||
ResponseFunctionResultDelta,
|
||||
ResponseTraceEvent,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseTraceEventDelta,
|
||||
ResponseUsageEventComplete,
|
||||
ResponseUsageEventDelta,
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseWorkflowEventDelta,
|
||||
)
|
||||
|
||||
# Type alias for compatibility
|
||||
@@ -41,6 +43,9 @@ OpenAIResponse = Response
|
||||
# Export all types for easy importing
|
||||
__all__ = [
|
||||
"AgentFrameworkRequest",
|
||||
"Conversation",
|
||||
"ConversationDeletedResource",
|
||||
"ConversationItem",
|
||||
"DiscoveryResponse",
|
||||
"EntityInfo",
|
||||
"InputTokensDetails",
|
||||
@@ -49,11 +54,15 @@ __all__ = [
|
||||
"OpenAIResponse",
|
||||
"OutputTokensDetails",
|
||||
"Response",
|
||||
"ResponseCompletedEvent",
|
||||
"ResponseErrorEvent",
|
||||
"ResponseFunctionCallArgumentsDeltaEvent",
|
||||
"ResponseFunctionResultComplete",
|
||||
"ResponseFunctionResultDelta",
|
||||
"ResponseFunctionToolCall",
|
||||
"ResponseFunctionToolCallOutputItem",
|
||||
"ResponseInputParam",
|
||||
"ResponseOutputItemAddedEvent",
|
||||
"ResponseOutputItemDoneEvent",
|
||||
"ResponseOutputMessage",
|
||||
"ResponseOutputText",
|
||||
"ResponseReasoningTextDeltaEvent",
|
||||
@@ -61,12 +70,8 @@ __all__ = [
|
||||
"ResponseTextDeltaEvent",
|
||||
"ResponseTraceEvent",
|
||||
"ResponseTraceEventComplete",
|
||||
"ResponseTraceEventDelta",
|
||||
"ResponseUsage",
|
||||
"ResponseUsageEventComplete",
|
||||
"ResponseUsageEventDelta",
|
||||
"ResponseWorkflowEventComplete",
|
||||
"ResponseWorkflowEventDelta",
|
||||
"ResponsesModel",
|
||||
"ToolParam",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""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.
|
||||
to support Agent Framework specific features like workflows and traces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,18 +15,6 @@ from pydantic import BaseModel, ConfigDict
|
||||
# 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."""
|
||||
|
||||
@@ -38,41 +26,6 @@ class ResponseWorkflowEventComplete(BaseModel):
|
||||
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."""
|
||||
|
||||
@@ -84,22 +37,18 @@ class ResponseTraceEventComplete(BaseModel):
|
||||
sequence_number: int
|
||||
|
||||
|
||||
class ResponseUsageEventDelta(BaseModel):
|
||||
"""Structured usage event with completion tracking."""
|
||||
class ResponseFunctionResultComplete(BaseModel):
|
||||
"""Custom DevUI event for function execution results.
|
||||
|
||||
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
|
||||
This is a DevUI extension - OpenAI doesn't stream function execution results
|
||||
because in their model, the application executes functions, not the API.
|
||||
Agent Framework executes functions, so we emit this event for debugging visibility.
|
||||
"""
|
||||
|
||||
|
||||
class ResponseUsageEventComplete(BaseModel):
|
||||
"""Complete usage event data."""
|
||||
|
||||
type: Literal["response.usage.complete"] = "response.usage.complete"
|
||||
data: dict[str, Any] # Complete usage data, not delta
|
||||
type: Literal["response.function_result.complete"] = "response.function_result.complete"
|
||||
call_id: str
|
||||
output: str
|
||||
status: Literal["in_progress", "completed", "incomplete"]
|
||||
item_id: str
|
||||
output_index: int = 0
|
||||
sequence_number: int
|
||||
@@ -110,7 +59,6 @@ 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
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -118,17 +66,21 @@ class AgentFrameworkExtraBody(BaseModel):
|
||||
|
||||
# Agent Framework Request Model - Extending real OpenAI types
|
||||
class AgentFrameworkRequest(BaseModel):
|
||||
"""OpenAI ResponseCreateParams with Agent Framework extensions.
|
||||
"""OpenAI ResponseCreateParams with Agent Framework routing.
|
||||
|
||||
This properly extends the real OpenAI API request format while adding
|
||||
our custom routing fields in extra_body.
|
||||
This properly extends the real OpenAI API request format.
|
||||
- Uses 'model' field as entity_id (agent/workflow name)
|
||||
- Uses 'conversation' field for conversation context (OpenAI standard)
|
||||
"""
|
||||
|
||||
# All OpenAI fields from ResponseCreateParams
|
||||
model: str
|
||||
model: str # Used as entity_id in DevUI!
|
||||
input: str | list[Any] # ResponseInputParam
|
||||
stream: bool | None = False
|
||||
|
||||
# OpenAI conversation parameter (standard!)
|
||||
conversation: str | dict[str, Any] | None = None # Union[str, {"id": str}]
|
||||
|
||||
# Common OpenAI optional fields
|
||||
instructions: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
@@ -136,32 +88,35 @@ class AgentFrameworkRequest(BaseModel):
|
||||
max_output_tokens: int | None = None
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
|
||||
# Agent Framework extension - strongly typed
|
||||
extra_body: AgentFrameworkExtraBody | None = None
|
||||
|
||||
entity_id: str | None = None # Allow entity_id as top-level field
|
||||
# Optional extra_body for advanced use cases
|
||||
extra_body: dict[str, Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
def get_entity_id(self) -> str | None:
|
||||
"""Get entity_id from either top-level field or extra_body."""
|
||||
# Priority 1: Top-level entity_id field
|
||||
if self.entity_id:
|
||||
return self.entity_id
|
||||
def get_entity_id(self) -> str:
|
||||
"""Get entity_id from model field.
|
||||
|
||||
# Priority 2: entity_id in extra_body
|
||||
if self.extra_body and hasattr(self.extra_body, "entity_id"):
|
||||
return self.extra_body.entity_id
|
||||
In DevUI, model IS the entity_id (agent/workflow name).
|
||||
Simple and clean!
|
||||
"""
|
||||
return self.model
|
||||
|
||||
def get_conversation_id(self) -> str | None:
|
||||
"""Extract conversation_id from conversation parameter.
|
||||
|
||||
Supports both string and object forms:
|
||||
- conversation: "conv_123"
|
||||
- conversation: {"id": "conv_123"}
|
||||
"""
|
||||
if isinstance(self.conversation, str):
|
||||
return self.conversation
|
||||
if isinstance(self.conversation, dict):
|
||||
return self.conversation.get("id")
|
||||
return None
|
||||
|
||||
def to_openai_params(self) -> dict[str, Any]:
|
||||
"""Convert to dict for OpenAI client compatibility."""
|
||||
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
|
||||
return self.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
# Error handling
|
||||
@@ -198,12 +153,7 @@ __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
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="/agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="/assets/index-D0SfShuZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-WsCIE0bH.css">
|
||||
<script type="module" crossorigin src="/assets/index-ZIs_B0ln.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhFnsoso.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user