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:
Victor Dibia
2025-10-08 19:34:30 +00:00
committed by GitHub
parent f5abbc67ae
commit c341ee7ed2
75 changed files with 4605 additions and 2646 deletions
@@ -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>