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 12:34:30 -07:00
committed by GitHub
Unverified
parent f5abbc67ae
commit c341ee7ed2
75 changed files with 4605 additions and 2646 deletions
+106 -13
View File
@@ -78,21 +78,65 @@ devui ./agents --tracing framework
## OpenAI-Compatible API
For convenience, you can interact with the agents/workflows using the standard OpenAI API format. Just specify the `entity_id` in the `extra_body` field. This can be an `agent_id` or `workflow_id`.
For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the model**, and set streaming to `True` as needed.
```bash
# Standard OpenAI format
# Simple - use your entity name as the model
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"model": "agent-framework",
"input": "Hello world",
"extra_body": {"entity_id": "weather_agent"}
"model": "weather_agent",
"input": "Hello world"
}
```
Or use the OpenAI Python SDK:
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="not-needed" # API key not required for local DevUI
)
response = client.responses.create(
model="weather_agent", # Your agent/workflow name
input="What's the weather in Seattle?"
)
# Extract text from response
print(response.output[0].content[0].text)
# Supports streaming with stream=True
```
### Multi-turn Conversations
Use the standard OpenAI `conversation` parameter for multi-turn conversations:
```python
# Create a conversation
conversation = client.conversations.create(
metadata={"agent_id": "weather_agent"}
)
# Use it across multiple turns
response1 = client.responses.create(
model="weather_agent",
input="What's the weather in Seattle?",
conversation=conversation.id
)
response2 = client.responses.create(
model="weather_agent",
input="How about tomorrow?",
conversation=conversation.id # Continues the conversation!
)
```
**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests.
## CLI Options
```bash
@@ -109,30 +153,79 @@ Options:
## Key Endpoints
## API Mapping
Given that DevUI offers an OpenAI Responses API, it internally maps messages and events from Agent Framework to OpenAI Responses API events (in `_mapper.py`). For transparency, this mapping is shown below:
| Agent Framework Content | OpenAI Event/Type | Status |
| ------------------------------- | ---------------------------------------- | -------- |
| `TextContent` | `response.output_text.delta` | Standard |
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | DevUI |
| `ErrorContent` | `response.error` | Standard |
| `UsageContent` | Final `Response.usage` field (not streamed) | Standard |
| `WorkflowEvent` | `response.workflow_event.complete` | DevUI |
| `DataContent`, `UriContent` | `response.trace.complete` | DevUI |
- **Standard** = OpenAI Responses API spec
- **DevUI** = Custom extensions for Agent Framework features (workflows, traces, function results)
### OpenAI Responses API Compliance
DevUI follows the OpenAI Responses API specification for maximum compatibility:
**Standard OpenAI Types Used:**
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls)
- `Response.usage` - Token usage (in final response, not streamed)
- All standard text, reasoning, and function call events
**Custom DevUI Extensions:**
- `response.function_result.complete` - Function execution results (DevUI executes functions, OpenAI doesn't)
- `response.workflow_event.complete` - Agent Framework workflow events
- `response.trace.complete` - Execution traces for debugging
These custom extensions are clearly namespaced and can be safely ignored by standard OpenAI clients.
### Entity Management
- `GET /v1/entities` - List discovered agents/workflows
- `GET /v1/entities/{entity_id}/info` - Get detailed entity information
- `POST /v1/entities/add` - Add entity from URL (for gallery samples)
- `DELETE /v1/entities/{entity_id}` - Remove remote entity
### Execution (OpenAI Responses API)
- `POST /v1/responses` - Execute agent/workflow (streaming or sync)
### Conversations (OpenAI Standard)
- `POST /v1/conversations` - Create conversation
- `GET /v1/conversations/{id}` - Get conversation
- `POST /v1/conversations/{id}` - Update conversation metadata
- `DELETE /v1/conversations/{id}` - Delete conversation
- `GET /v1/conversations?agent_id={id}` - List conversations _(DevUI extension)_
- `POST /v1/conversations/{id}/items` - Add items to conversation
- `GET /v1/conversations/{id}/items` - List conversation items
- `GET /v1/conversations/{id}/items/{item_id}` - Get conversation item
### Health
- `GET /health` - Health check
- `POST /v1/threads` - Create thread for agent (optional)
- `GET /v1/threads?agent_id={id}` - List threads for agent
- `GET /v1/threads/{thread_id}` - Get thread info
- `DELETE /v1/threads/{thread_id}` - Delete thread
- `GET /v1/threads/{thread_id}/messages` - Get thread messages
## Implementation
- **Discovery**: `agent_framework_devui/_discovery.py`
- **Execution**: `agent_framework_devui/_executor.py`
- **Message Mapping**: `agent_framework_devui/_mapper.py`
- **Session Management**: `agent_framework_devui/_session.py`
- **Conversations**: `agent_framework_devui/_conversations.py`
- **API Server**: `agent_framework_devui/_server.py`
- **CLI**: `agent_framework_devui/_cli.py`
## Examples
See `samples/` for working agent and workflow implementations.
See working implementations in `python/samples/getting_started/devui/`
## License
@@ -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>
+65 -33
View File
@@ -9,8 +9,6 @@ git clone https://github.com/microsoft/agent-framework.git
cd agent-framework
```
(or use the latest main branch if merged)
## 2. Setup Environment
Navigate to the Python directory and install dependencies:
@@ -47,7 +45,7 @@ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name"
**Option A: In-Memory Mode (Recommended for quick testing)**
```bash
cd packages/devui/samples
cd samples/getting_started/devui
python in_memory_mode.py
```
@@ -56,7 +54,7 @@ This runs a simple example with predefined agents and opens your browser automat
**Option B: Directory-Based Discovery**
```bash
cd packages/devui/samples
cd samples/getting_started/devui
devui
```
@@ -72,57 +70,91 @@ This launches the UI with all example agents/workflows at http://localhost:8080
You can also test via API calls:
### Single Request
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "agent-framework",
"model": "weather_agent",
"input": "What is the weather in Seattle?"
}'
```
### Multi-turn Conversations
```bash
# Create a conversation
curl -X POST http://localhost:8080/v1/conversations \
-H "Content-Type: application/json" \
-d '{"metadata": {"agent_id": "weather_agent"}}'
# Returns: {"id": "conv_abc123", ...}
# Use conversation ID in requests
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
"input": "What is the weather in Seattle?",
"extra_body": {"entity_id": "weather_agent"}
"conversation": "conv_abc123"
}'
# Continue the conversation
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
"input": "How about tomorrow?",
"conversation": "conv_abc123"
}'
```
## API Mapping
Messages and events from agents/workflows are mapped to OpenAI response types in `agent_framework_devui/_mapper.py`. See the mapping table below:
Agent Framework content types → OpenAI Responses API events (in `_mapper.py`):
| Agent Framework Content | OpenAI Event | Type |
| --------------------------------- | ----------------------------------------- | -------- |
| `TextContent` | `ResponseTextDeltaEvent` | Official |
| `TextReasoningContent` | `ResponseReasoningTextDeltaEvent` | Official |
| `FunctionCallContent` | `ResponseFunctionCallArgumentsDeltaEvent` | Official |
| `FunctionResultContent` | `ResponseFunctionResultComplete` | Custom |
| `ErrorContent` | `ResponseErrorEvent` | Official |
| `UsageContent` | `ResponseUsageEventComplete` | Custom |
| `DataContent` | `ResponseTraceEventComplete` | Custom |
| `UriContent` | `ResponseTraceEventComplete` | Custom |
| `HostedFileContent` | `ResponseTraceEventComplete` | Custom |
| `HostedVectorStoreContent` | `ResponseTraceEventComplete` | Custom |
| `FunctionApprovalRequestContent` | Custom event | Custom |
| `FunctionApprovalResponseContent` | Custom event | Custom |
| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom |
| Agent Framework Content | OpenAI Event | Status |
| ------------------------------- | ---------------------------------------- | -------- |
| `TextContent` | `response.output_text.delta` | Standard |
| `TextReasoningContent` | `response.reasoning.delta` | Standard |
| `FunctionCallContent` (initial) | `response.output_item.added` | Standard |
| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard |
| `FunctionResultContent` | `response.function_result.complete` | Standard |
| `ErrorContent` | `response.error` | Standard |
| `UsageContent` | `response.usage.complete` | Extended |
| `WorkflowEvent` | `response.workflow.event` | DevUI |
| `DataContent`, `UriContent` | `response.trace.complete` | DevUI |
- **Standard** = OpenAI spec, **Extended** = OpenAI + extra fields, **DevUI** = DevUI-specific
## Frontend Development
To build the frontend:
```bash
cd frontend
cd python/packages/devui/frontend
yarn install
# Create .env.local with backend URL
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
# Create .env.production (empty for relative URLs)
echo '' > .env.production
# Development
# Development (hot reload)
yarn dev
# Build (copies to backend)
# Build (copies to backend ui/)
yarn build
```
## Running Tests
```bash
cd python/packages/devui
# All tests
pytest tests/ -v
# Specific suites
pytest tests/test_conversations.py -v # Conversation store
pytest tests/test_server.py -v # API endpoints
pytest tests/test_mapper.py -v # Event mapping
```
## Troubleshooting
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI.
+4 -6
View File
@@ -4,12 +4,10 @@
*/
import { useState, useEffect, useCallback } from "react";
import { AppHeader } from "@/components/shared/app-header";
import { DebugPanel } from "@/components/shared/debug-panel";
import { SettingsModal } from "@/components/shared/settings-modal";
import { GalleryView } from "@/components/gallery";
import { AgentView } from "@/components/agent/agent-view";
import { WorkflowView } from "@/components/workflow/workflow-view";
import { AppHeader, DebugPanel, SettingsModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
import { WorkflowView } from "@/components/features/workflow";
import { LoadingState } from "@/components/ui/loading-state";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
@@ -0,0 +1,7 @@
/**
* Agent Feature - Exports
*/
export { AgentView } from "./agent-view";
export { AgentDetailsModal } from "./agent-details-modal";
export * from "./message-renderers";
@@ -0,0 +1,289 @@
/**
* OpenAI Content Renderer - Renders OpenAI Conversations API content types
* This is the CORRECT implementation that works with OpenAI types only
*/
import { useState } from "react";
import {
Download,
FileText,
Code,
ChevronDown,
ChevronUp,
Music,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
interface ContentRendererProps {
content: MessageContent;
className?: string;
isStreaming?: boolean;
}
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "text") return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
{shouldTruncate && (
<div className="flex justify-end mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center gap-1 text-xs
bg-background/80 hover:bg-background border border-border/50 hover:border-border
text-muted-foreground hover:text-foreground
transition-colors cursor-pointer px-2 py-1 rounded"
>
{isExpanded ? (
<>
less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
)}
</div>
);
}
// Image content renderer
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image") return null;
const imageUrl = content.image_url;
if (imageError) {
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>Image could not be loaded</span>
</div>
</div>
);
}
return (
<div className={`my-2 ${className || ""}`}>
<img
src={imageUrl}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
{isExpanded && (
<div className="text-xs text-muted-foreground mt-1">
Click to collapse
</div>
)}
</div>
);
}
// File content renderer
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
// Determine file type from filename or data URI
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
const isAudio = filename?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/);
// For PDFs, try to embed
if (isPdf && fileUrl) {
return (
<div className={`my-2 ${className || ""}`}>
<div className="border rounded-lg overflow-hidden">
<iframe
src={fileUrl}
className="w-full h-96"
title={filename}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{filename}</span>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="ml-auto text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// For audio files
if (isAudio && fileUrl) {
return (
<div className={`my-2 p-3 border rounded-lg ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{filename}</span>
</div>
<audio controls className="w-full">
<source src={fileUrl} />
Your browser does not support audio playback.
</audio>
</div>
);
}
// Generic file display
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{filename}</span>
</div>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</a>
)}
</div>
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
case "text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
return <FileContentRenderer content={content} className={className} />;
default:
return null;
}
}
// Function call renderer (for displaying function calls in chat)
interface FunctionCallRendererProps {
name: string;
arguments: string;
className?: string;
}
export function FunctionCallRenderer({ name, arguments: args, className }: FunctionCallRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedArgs;
try {
parsedArgs = typeof args === "string" ? JSON.parse(args) : args;
} catch {
parsedArgs = args;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-blue-600 dark:text-blue-400 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Function result renderer
interface FunctionResultRendererProps {
output: string;
call_id: string;
className?: string;
}
export function FunctionResultRenderer({ output, call_id, className }: FunctionResultRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedOutput;
try {
parsedOutput = typeof output === "string" ? JSON.parse(output) : output;
} catch {
parsedOutput = output;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className="text-green-600 dark:text-green-400 mb-1">Output:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedOutput, null, 2)}
</pre>
<div className="text-gray-500 text-[10px] mt-2">Call ID: {call_id}</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
/**
* OpenAI Message Renderer - Renders OpenAI ConversationItem types
* This replaces the legacy AgentFramework-based renderer
*/
import type { ConversationItem } from "@/types/openai";
import {
OpenAIContentRenderer,
FunctionCallRenderer,
FunctionResultRenderer,
} from "./OpenAIContentRenderer";
interface OpenAIMessageRendererProps {
item: ConversationItem;
className?: string;
}
export function OpenAIMessageRenderer({
item,
className,
}: OpenAIMessageRendererProps) {
// Handle message items (user/assistant with content)
if (item.type === "message") {
// Determine if message is actively streaming
const isStreaming = item.status === "in_progress";
const hasContent = item.content.length > 0;
return (
<div className={className}>
{item.content.map((content, index) => (
<OpenAIContentRenderer
key={index}
content={content}
className={index > 0 ? "mt-2" : ""}
isStreaming={isStreaming}
/>
))}
{/* Show typing indicator when streaming with no content yet */}
{isStreaming && !hasContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
// Handle function call items
if (item.type === "function_call") {
return (
<FunctionCallRenderer
name={item.name}
arguments={item.arguments}
className={className}
/>
);
}
// Handle function result items
if (item.type === "function_call_output") {
return (
<FunctionResultRenderer
output={item.output}
call_id={item.call_id}
className={className}
/>
);
}
// Unknown item type
return null;
}
@@ -0,0 +1,7 @@
/**
* Message Renderer - Exports
* Uses OpenAI Responses API types exclusively
*/
export { OpenAIMessageRenderer } from "./OpenAIMessageRenderer";
export { OpenAIContentRenderer, FunctionCallRenderer, FunctionResultRenderer } from "./OpenAIContentRenderer";
@@ -0,0 +1,9 @@
/**
* Workflow Feature - Exports
*/
export { WorkflowView } from "./workflow-view";
export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useEffect } from "react";
import { useMemo, useCallback, useEffect, memo } from "react";
import {
MoreVertical,
Map,
@@ -248,7 +248,7 @@ function WorkflowAnimationHandler({
return null; // This component doesn't render anything
}
export function WorkflowFlow({
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
@@ -290,8 +290,8 @@ export function WorkflowFlow({
// Process events and update node/edge states
const nodeUpdates = useMemo(() => {
return processWorkflowEvents(events);
}, [events]);
return processWorkflowEvents(events, workflowDump?.start_executor_id);
}, [events, workflowDump?.start_executor_id]);
// Update nodes and edges with real-time state from events
useMemo(() => {
@@ -514,4 +514,4 @@ export function WorkflowFlow({
`}</style>
</div>
);
}
});
@@ -13,20 +13,22 @@ import {
RotateCcw,
Info,
Workflow as WorkflowIcon,
Maximize2,
ChevronsDown,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
import { WorkflowInputForm } from "./workflow-input-form";
import { Button } from "@/components/ui/button";
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
import { WorkflowFlow } from "./workflow-flow";
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
import { WorkflowDetailsModal } from "@/components/shared/workflow-details-modal";
import { WorkflowDetailsModal } from "./workflow-details-modal";
import { apiClient } from "@/services/api";
import type {
WorkflowInfo,
ExtendedResponseStreamEvent,
JSONSchemaProperty,
} from "@/types";
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
import type { ExecutorNodeData } from "./executor-node";
import {
Dialog,
DialogContent,
@@ -269,8 +271,17 @@ export function WorkflowView({
useState<ExecutorNodeData | null>(null);
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [resultModalOpen, setResultModalOpen] = useState(false);
const [errorModalOpen, setErrorModalOpen] = useState(false);
const resultContentRef = useRef<HTMLDivElement>(null);
const errorContentRef = useRef<HTMLDivElement>(null);
const [isErrorScrollable, setIsErrorScrollable] = useState(false);
// Track per-executor outputs and workflow metadata
const executorOutputs = useRef<Record<string, string>>({});
const currentStreamingExecutor = useRef<string | null>(null);
const workflowMetadata = useRef<Record<string, unknown> | null>(null);
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
@@ -312,6 +323,40 @@ export function WorkflowView({
localStorage.setItem("workflowLayoutDirection", layoutDirection);
}, [layoutDirection]);
// Auto-scroll output panel when new content arrives (if user is at bottom)
useEffect(() => {
const handleAutoScroll = () => {
if (resultContentRef.current) {
const container = resultContentRef.current;
const isScrollable = container.scrollHeight > container.clientHeight;
// Check if user is near the bottom (within 100px threshold)
const scrollBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const isNearBottom = scrollBottom < 100;
// Auto-scroll smoothly if user is near bottom and content is streaming
if (isStreaming && isNearBottom && isScrollable) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}
}
if (errorContentRef.current) {
const isScrollable =
errorContentRef.current.scrollHeight >
errorContentRef.current.clientHeight;
setIsErrorScrollable(isScrollable);
}
};
handleAutoScroll();
// Recheck on window resize
window.addEventListener("resize", handleAutoScroll);
return () => window.removeEventListener("resize", handleAutoScroll);
}, [workflowResult, workflowError, bottomPanelHeight, isStreaming]);
// View option handlers
const toggleViewOption = (key: keyof typeof viewOptions) => {
setViewOptions((prev: typeof viewOptions) => ({
@@ -330,8 +375,8 @@ export function WorkflowView({
const info = await apiClient.getWorkflowInfo(selectedWorkflow.id);
setWorkflowInfo(info);
} catch (error) {
console.error("Failed to load workflow info:", error);
setWorkflowInfo(null);
console.error("Error loading workflow info:", error);
} finally {
setWorkflowLoading(false);
}
@@ -343,7 +388,9 @@ export function WorkflowView({
setSelectedExecutor(null);
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
executorOutputs.current = {};
currentStreamingExecutor.current = null;
workflowMetadata.current = null;
loadWorkflowInfo();
}, [selectedWorkflow.id, selectedWorkflow.type]);
@@ -351,6 +398,14 @@ export function WorkflowView({
const handleNodeSelect = (executorId: string, data: ExecutorNodeData) => {
setSelectedExecutor(data);
selectExecutor(executorId);
// Update result display to show selected executor's output
if (executorOutputs.current[executorId]) {
// Show per-executor output if available
setWorkflowResult(executorOutputs.current[executorId]);
}
// Note: For executors without output, we don't clear workflowResult
// This preserves the workflow's final output for display
};
// Extract workflow events from OpenAI events for executor tracking
@@ -360,29 +415,39 @@ export function WorkflowView({
);
}, [openAIEvents]);
// Extract executor history from workflow events
// Extract executor history from workflow events (filter out workflow-level events)
const executorHistory = useMemo(() => {
return workflowEvents.map((event) => {
if ("data" in event && event.data && typeof event.data === "object") {
const data = event.data as Record<string, unknown>;
return workflowEvents
.filter((event) => {
if ("data" in event && event.data && typeof event.data === "object") {
const data = event.data as Record<string, unknown>;
// Filter out workflow-level events (those without executor_id)
// These include: WorkflowStartedEvent, WorkflowOutputEvent, WorkflowStatusEvent, etc.
return data.executor_id != null;
}
return false;
})
.map((event) => {
if ("data" in event && event.data && typeof event.data === "object") {
const data = event.data as Record<string, unknown>;
return {
executorId: String(data.executor_id),
message: String(data.event_type || "Processing"),
timestamp: String(data.timestamp || new Date().toISOString()),
status: String(data.event_type || "").includes("Completed")
? ("completed" as const)
: String(data.event_type || "").includes("Error")
? ("error" as const)
: ("running" as const),
};
}
return {
executorId: String(data.executor_id || "unknown"),
message: String(data.event_type || "Processing"),
timestamp: String(data.timestamp || new Date().toISOString()),
status: String(data.event_type || "").includes("Completed")
? ("completed" as const)
: String(data.event_type || "").includes("Error")
? ("error" as const)
: ("running" as const),
executorId: "unknown",
message: "Processing",
timestamp: new Date().toISOString(),
status: "running" as const,
};
}
return {
executorId: "unknown",
message: "Processing",
timestamp: new Date().toISOString(),
status: "running" as const,
};
});
});
}, [workflowEvents]);
// Track active executors
@@ -441,7 +506,11 @@ export function WorkflowView({
setOpenAIEvents([]); // Clear previous OpenAI events for new execution
setWorkflowResult("");
setWorkflowError("");
accumulatedText.current = "";
// Clear per-executor outputs and metadata for new run
executorOutputs.current = {};
currentStreamingExecutor.current = null;
workflowMetadata.current = null;
// Clear debug panel events for new workflow run
onDebugEvent("clear");
@@ -456,23 +525,16 @@ export function WorkflowView({
);
for await (const openAIEvent of streamGenerator) {
// Store all events for processing
setOpenAIEvents((prev) => [...prev, openAIEvent]);
// Only store workflow events in state for performance
// Text deltas are processed directly without state updates
if (openAIEvent.type === "response.workflow_event.complete") {
setOpenAIEvents((prev) => [...prev, openAIEvent]);
}
// Pass to debug panel
onDebugEvent(openAIEvent);
// Handle text output for workflow result
if (
openAIEvent.type === "response.output_text.delta" &&
"delta" in openAIEvent &&
openAIEvent.delta
) {
accumulatedText.current += openAIEvent.delta;
setWorkflowResult(accumulatedText.current);
}
// Handle workflow completion with final result
// Handle workflow events to track current executor
if (
openAIEvent.type === "response.workflow_event.complete" &&
"data" in openAIEvent &&
@@ -481,13 +543,72 @@ export function WorkflowView({
const data = openAIEvent.data as {
event_type?: string;
data?: unknown;
executor_id?: string | null;
};
// Track when executor starts (to know which executor is streaming)
if (
data.event_type === "ExecutorInvokedEvent" &&
data.executor_id
) {
currentStreamingExecutor.current = data.executor_id;
// Initialize output for this executor if not exists
if (!executorOutputs.current[data.executor_id]) {
executorOutputs.current[data.executor_id] = "";
}
}
// Handle workflow completion and output events
if (
(data.event_type === "WorkflowCompletedEvent" ||
data.event_type === "WorkflowOutputEvent") &&
data.data
) {
setWorkflowResult(String(data.data));
// For workflows that don't emit text deltas (e.g., ctx.yield_output),
// the WorkflowOutputEvent contains the final output
if (typeof data.data === "string") {
setWorkflowResult(data.data);
} else {
// Store object data and display as formatted JSON
workflowMetadata.current = data.data as Record<string, unknown>;
const jsonOutput = JSON.stringify(data.data, null, 2);
setWorkflowResult(jsonOutput);
}
currentStreamingExecutor.current = null;
}
}
// Handle text output - assign to current executor
if (
openAIEvent.type === "response.output_text.delta" &&
"delta" in openAIEvent &&
openAIEvent.delta
) {
// Determine which executor owns this text
const executorId = currentStreamingExecutor.current;
if (executorId) {
// Initialize executor output if needed
if (!executorOutputs.current[executorId]) {
executorOutputs.current[executorId] = "";
}
// Append to specific executor's output
executorOutputs.current[executorId] += openAIEvent.delta;
// Update display based on what should be shown
if (
selectedExecutor &&
executorOutputs.current[selectedExecutor.executorId]
) {
// If user has selected an executor, show that executor's output
setWorkflowResult(
executorOutputs.current[selectedExecutor.executorId]
);
} else {
// Otherwise show current streaming executor's output
setWorkflowResult(executorOutputs.current[executorId]);
}
}
}
@@ -502,17 +623,15 @@ export function WorkflowView({
}
}
// Stream ended
setIsStreaming(false);
} catch (error) {
console.error("Workflow execution failed:", error);
setWorkflowError(
error instanceof Error ? error.message : "Unknown error"
);
setIsStreaming(false);
}
},
[selectedWorkflow, onDebugEvent]
[selectedWorkflow, onDebugEvent, workflowInfo]
);
// Show loading state when workflow is being loaded
@@ -594,7 +713,7 @@ export function WorkflowView({
{workflowInfo?.workflow_dump && (
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
events={workflowEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
@@ -626,17 +745,17 @@ export function WorkflowView({
{/* Bottom Panel - Execution Details */}
<div
className="flex-shrink-0 border-t"
className="flex-shrink-0 border-t overflow-hidden"
style={{ height: `${bottomPanelHeight}px` }}
>
{/* Full Width - Execution Details */}
<div className="flex-1 min-w-0 p-4 overflow-auto">
<div className="h-full flex gap-4 p-4">
{selectedExecutor ||
activeExecutors.length > 0 ||
executorHistory.length > 0 ||
workflowResult ||
workflowError ? (
<div className="h-full flex gap-4">
<>
{/* Current/Last Executor Panel */}
{(selectedExecutor ||
activeExecutors.length > 0 ||
@@ -831,28 +950,106 @@ export function WorkflowView({
</div>
)}
{/* Enhanced Result Display */}
{workflowResult && (
<div className="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow flex-1 flex flex-col">
<div className="border-b border-emerald-300 dark:border-emerald-600 px-4 py-3 bg-emerald-100 dark:bg-emerald-900/50 rounded-t flex-shrink-0">
<div className="flex items-center gap-3">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Workflow Complete
</h4>
{/* Output Panel - displays workflow execution results and streaming output */}
{workflowResult &&
(() => {
// Determine the panel state and styling
const isStreamingState =
isStreaming && currentStreamingExecutor.current;
const isSelectedExecutor = !isStreaming && selectedExecutor;
// Define theme based on state - use colors sparingly (borders/icons only, not text)
const theme = isStreamingState
? {
// Purple theme when streaming (matches running node color #643FB2)
border: "border-[#643FB2]/40 dark:border-[#8B5CF6]/40",
bg: "bg-[#643FB2]/5 dark:bg-[#8B5CF6]/5",
headerBg: "bg-[#643FB2]/10 dark:bg-[#8B5CF6]/10",
icon: (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />
),
buttonBg: "bg-background dark:bg-background",
buttonBorder:
"border-[#643FB2]/30 dark:border-[#8B5CF6]/30",
buttonHover:
"hover:bg-[#643FB2]/10 dark:hover:bg-[#8B5CF6]/10",
}
: isSelectedExecutor
? {
// Blue theme when executor selected (matches selected node ring blue-500)
border: "border-blue-500/40 dark:border-blue-500/40",
bg: "bg-blue-500/5 dark:bg-blue-500/5",
headerBg: "bg-blue-500/10 dark:bg-blue-500/10",
icon: (
<Info className="w-4 h-4 text-blue-500 dark:text-blue-400" />
),
buttonBg: "bg-background dark:bg-background",
buttonBorder:
"border-blue-500/30 dark:border-blue-500/30",
buttonHover:
"hover:bg-blue-500/10 dark:hover:bg-blue-500/10",
}
: {
// Green theme when workflow complete (matches completed node green-500)
border: "border-green-500/40 dark:border-green-400/40",
bg: "bg-green-500/5 dark:bg-green-400/5",
headerBg: "bg-green-500/10 dark:bg-green-400/10",
icon: (
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
),
buttonBg: "bg-background dark:bg-background",
buttonBorder:
"border-green-500/30 dark:border-green-400/30",
buttonHover:
"hover:bg-green-500/10 dark:hover:bg-green-400/10",
};
return (
<div
className={`border-2 ${theme.border} rounded ${theme.bg} shadow flex-1 flex flex-col min-w-0 relative`}
>
<div
className={`border-b ${theme.border} px-4 py-3 ${theme.headerBg} rounded-t flex-shrink-0`}
>
<div className="flex items-center gap-3">
{theme.icon}
<h4 className="text-sm font-semibold text-foreground">
{isStreamingState
? `Output: ${currentStreamingExecutor.current}`
: isSelectedExecutor
? `Output: ${selectedExecutor.executorId}`
: "Workflow Complete"}
</h4>
</div>
</div>
<div
ref={resultContentRef}
className="p-4 overflow-auto flex-1 min-h-0 relative"
>
<div className="text-foreground whitespace-pre-wrap break-words text-sm pb-12">
{workflowResult}
</div>
</div>
{/* Sticky "View Full" button - always visible at bottom-right */}
<div className="absolute bottom-3 right-3 pointer-events-auto">
<Button
variant="outline"
size="sm"
onClick={() => setResultModalOpen(true)}
className={`h-8 px-3 ${theme.buttonBg} ${theme.buttonBorder} ${theme.buttonHover} shadow-md`}
title="Expand to full view"
>
<Maximize2 className="w-3.5 h-3.5 mr-1.5" />
View Full
</Button>
</div>
</div>
</div>
<div className="p-4 overflow-auto flex-1">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
{workflowResult}
</div>
</div>
</div>
)}
);
})()}
{/* Enhanced Error Display */}
{workflowError && (
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col">
<div className="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col min-w-0 relative">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t flex-shrink-0">
<div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive" />
@@ -861,17 +1058,42 @@ export function WorkflowView({
</h4>
</div>
</div>
<div className="p-4 overflow-auto flex-1">
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
<div
ref={errorContentRef}
className="p-4 overflow-auto flex-1 min-h-0 relative"
>
<div className="text-destructive whitespace-pre-wrap break-words text-sm pb-12">
{workflowError}
</div>
</div>
{/* Sticky "View Full" button - always visible at bottom-right */}
<div className="absolute bottom-3 right-3 pointer-events-auto">
<Button
variant="outline"
size="sm"
onClick={() => setErrorModalOpen(true)}
className="h-8 px-3 bg-destructive/10 dark:bg-destructive/20 border-destructive/50 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/30 shadow-md"
title="Expand to full view"
>
<Maximize2 className="w-3.5 h-3.5 mr-1.5" />
View Full
</Button>
</div>
{/* Scroll indicator - only show when scrollable */}
{isErrorScrollable && (
<div className="absolute bottom-14 left-1/2 transform -translate-x-1/2 pointer-events-none">
<div className="bg-destructive/80 text-white px-2 py-1 rounded-full flex items-center gap-1 text-xs animate-bounce">
<ChevronsDown className="w-3 h-3" />
<span>Scroll for more</span>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a workflow to see execution details</p>
<p>Select a workflow node (executor) to see execution details</p>
</div>
)}
</div>
@@ -883,6 +1105,93 @@ export function WorkflowView({
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>
{/* Result Full View Modal */}
<Dialog open={resultModalOpen} onOpenChange={setResultModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
Workflow Results
</DialogTitle>
<DialogClose onClose={() => setResultModalOpen(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
<div className="space-y-4">
{/* Show per-executor outputs if we have multiple executors with output */}
{Object.values(executorOutputs.current).some(
(output) => output && output.trim().length > 0
) ? (
Object.entries(executorOutputs.current).map(
([executorId, output]) =>
output && (
<div
key={executorId}
className="border-2 border-emerald-300 dark:border-emerald-600 rounded overflow-hidden"
>
<div className="bg-emerald-100 dark:bg-emerald-900/50 px-4 py-2 border-b border-emerald-300 dark:border-emerald-600">
<h5 className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
{executorId}
</h5>
</div>
<div className="bg-emerald-50 dark:bg-emerald-950/50 p-4">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm font-mono">
{output}
</div>
</div>
</div>
)
)
) : (
/* Show workflow result for simple workflows without per-executor tracking */
<div className="bg-emerald-50 dark:bg-emerald-950/50 rounded border-2 border-emerald-300 dark:border-emerald-600 p-6">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm font-mono">
{workflowResult || "No output available"}
</div>
</div>
)}
{/* Show workflow output if available */}
{workflowMetadata.current && (
<div className="border-2 border-blue-300 dark:border-blue-600 rounded overflow-hidden">
<div className="bg-blue-100 dark:bg-blue-900/50 px-4 py-2 border-b border-blue-300 dark:border-blue-600">
<h5 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
Workflow Output (Structured)
</h5>
</div>
<div className="bg-blue-50 dark:bg-blue-950/50 p-4">
<pre className="text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-words text-xs font-mono">
{JSON.stringify(workflowMetadata.current, null, 2)}
</pre>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Error Full View Modal */}
<Dialog open={errorModalOpen} onOpenChange={setErrorModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-destructive" />
Workflow Error
</DialogTitle>
<DialogClose onClose={() => setErrorModalOpen(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
<div className="bg-destructive/5 rounded border-2 border-destructive/70 p-6">
<div className="text-destructive whitespace-pre-wrap break-words text-sm font-mono">
{workflowError}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -4,7 +4,7 @@
*/
import { Button } from "@/components/ui/button";
import { EntitySelector } from "@/components/shared/entity-selector";
import { EntitySelector } from "./entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
@@ -73,7 +73,7 @@ export function AppHeader({
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
<Button variant="ghost" size="sm" onClick={onSettingsClick}>
<Button variant="ghost" size="sm" onClick={(e: React.MouseEvent) => { e.stopPropagation(); onSettingsClick?.(); }}>
<Settings className="h-4 w-4" />
</Button>
</div>
@@ -32,12 +32,6 @@ interface EventDataBase {
[key: string]: unknown;
}
interface FunctionResultData extends EventDataBase {
result?: unknown;
status?: "completed" | "failed";
exception?: string;
}
interface FunctionCallData extends EventDataBase {
name?: string;
arguments?: string | object;
@@ -70,6 +64,24 @@ interface DebugPanelProps {
onClose?: () => void;
}
// Helper: Extract function result from DevUI custom format
function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
call_id: string;
output: string;
status: string;
} | null {
if (event.type === "response.function_result.complete") {
const resultEvent =
event as import("@/types").ResponseFunctionResultComplete;
return {
call_id: resultEvent.call_id,
output: resultEvent.output,
status: resultEvent.status,
};
}
return null;
}
// Helper function to accumulate OpenAI events into meaningful units
function processEventsForDisplay(
events: ExtendedResponseStreamEvent[]
@@ -81,22 +93,53 @@ function processEventsForDisplay(
name?: string;
arguments: string;
callId: string;
itemId?: string; // Track item_id for delta matching
timestamp: string;
}
>();
const callIdToName = new Map<string, string>(); // Track call_id -> function name mappings
let accumulatedText = "";
const lastFunctionCallId: string | null = null; // Track the most recent function call
for (const event of events) {
// Handle response.output_item.added - NEW! Extract function call metadata
if (event.type === "response.output_item.added") {
const outputEvent = event as import("@/types").ResponseOutputItemAddedEvent;
const item = outputEvent.item;
// If it's a function call item, extract metadata
if (item.type === "function_call" && item.call_id && item.name) {
const callId = item.call_id;
// Initialize function call tracking with REAL function name from backend!
functionCalls.set(callId, {
name: item.name, // ← REAL NAME! (not "unknown")
arguments: "",
callId: callId,
itemId: item.id, // Track item_id for delta matching
timestamp: new Date().toISOString(),
});
// Also track in callIdToName map for result pairing
callIdToName.set(callId, item.name);
}
// Pass through the event for display
processedEvents.push(event);
continue;
}
// Check if this is a function result (OpenAI standard format)
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
// Always show completion, error, workflow events, and function results
if (
event.type === "response.completed" ||
event.type === "response.done" ||
event.type === "error" ||
event.type === "response.workflow_event.complete" ||
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete" ||
event.type === "response.function_result.complete"
isFunctionResult
) {
// Flush any accumulated text before showing these events
if (accumulatedText.trim()) {
@@ -140,12 +183,9 @@ function processEventsForDisplay(
}
// For function results, ensure we have the corresponding function call
if (
event.type === "response.function_result.complete" &&
"data" in event
) {
const resultData = event.data as FunctionResultData;
const callId = resultData.call_id;
const functionResult = getFunctionResultFromEvent(event);
if (functionResult) {
const callId = functionResult.call_id;
// Only create function call event if we have actual argument data
if (callId && functionCalls.has(callId)) {
@@ -198,55 +238,52 @@ function processEventsForDisplay(
continue;
}
// Handle function call arguments accumulation - ACTUAL BACKEND FORMAT
// Handle function call arguments accumulation - UPDATED to use item_id
if (event.type === "response.function_call_arguments.delta") {
let deltaData: string = "";
let callId: string;
let callId: string | null = null;
// Extract delta from actual backend format
if ("delta" in event && typeof event.delta === "string") {
deltaData = event.delta;
}
// Use a simple tracking approach since backend doesn't provide call_id in argument deltas
if (
"data" in event &&
event.data &&
(event.data as EventDataBase).call_id
) {
callId = String((event.data as EventDataBase).call_id);
} else {
callId = lastFunctionCallId || `call_${Date.now()}`;
// NEW: Use item_id to find the matching function call
// Since backend now uses call_id as item_id, we can match directly
if ("item_id" in event && event.item_id) {
const itemId = event.item_id;
// Find function call by item_id (which equals call_id in our implementation)
for (const [cId, call] of functionCalls.entries()) {
if (call.itemId === itemId || cId === itemId) {
callId = cId;
break;
}
}
}
if (deltaData && callId) {
// Ensure we have a function call entry
if (!functionCalls.has(callId)) {
functionCalls.set(callId, {
name: "unknown", // Backend doesn't provide function name in these events
arguments: "",
callId,
timestamp: new Date().toISOString(),
});
}
const call = functionCalls.get(callId);
// Accumulate the delta
const call = functionCalls.get(callId)!;
if (call) {
// Function name should already be set from output_item.added event
// Just accumulate arguments
// Skip the initial "{}" delta that backend sends
if (deltaData === "{}" && call.arguments === "") {
continue;
}
// Skip the initial "{}" delta that backend sends
if (deltaData === "{}" && call.arguments === "") {
continue;
}
// Accumulate and clean up the delta
let cleanedDelta = deltaData;
try {
// Remove extra quotes and escaping that backend adds
cleanedDelta = deltaData.replace(/^"|"$/g, "").replace(/\\"/g, '"');
} catch {
cleanedDelta = deltaData;
// Accumulate the delta (no cleaning needed - use raw delta)
call.arguments += deltaData;
} else {
// Shouldn't happen if output_item.added was emitted first
console.warn(
`Received argument delta for unknown call with item_id: ${
"item_id" in event ? event.item_id : "unknown"
}`
);
}
call.arguments += cleanedDelta;
}
continue;
}
@@ -351,20 +388,22 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
}
return "Function arguments...";
case "response.function_result.complete":
if ("data" in event && event.data) {
const data = event.data as FunctionResultData;
const resultStr = data.result
? typeof data.result === "string"
? data.result
: JSON.stringify(data.result)
: "no result";
const truncated = resultStr.slice(0, 40);
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
const truncated = result.output.slice(0, 40);
return `Tool result: ${truncated}${
truncated.length >= 40 ? "..." : ""
}`;
}
return "Function result";
// Could also be a function call
const addedEvent =
event as import("@/types").ResponseOutputItemAddedEvent;
if (addedEvent.item.type === "function_call") {
return `Tool call: ${addedEvent.item.name}`;
}
return "Output item added";
}
case "response.workflow_event.complete":
if ("data" in event && event.data) {
@@ -381,6 +420,16 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
}
return "Trace event";
case "response.completed":
if ("response" in event && event.response && "usage" in event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const usage = completedEvent.response.usage;
if (usage) {
return `Response complete (${usage.total_tokens} tokens)`;
}
}
return "Response complete";
case "response.done":
return "Response complete";
@@ -404,13 +453,15 @@ function getEventIcon(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return Wrench;
case "response.function_result.complete":
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
return Activity;
case "response.trace_event.complete":
case "response.trace.complete":
return Search;
case "response.completed":
return CheckCircle2;
case "response.done":
return CheckCircle2;
case "error":
@@ -428,13 +479,15 @@ function getEventColor(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return "text-blue-600 dark:text-blue-400";
case "response.function_result.complete":
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.complete":
return "text-purple-600 dark:text-purple-400";
case "response.trace_event.complete":
case "response.trace.complete":
return "text-orange-600 dark:text-orange-400";
case "response.completed":
return "text-green-600 dark:text-green-400";
case "response.done":
return "text-green-600 dark:text-green-400";
case "error":
@@ -456,9 +509,8 @@ function EventItem({ event }: EventItemProps) {
(event.type === "response.function_call.complete" &&
"data" in event &&
event.data) ||
(event.type === "response.function_result.complete" &&
"data" in event &&
event.data) ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data) ||
@@ -472,6 +524,9 @@ function EventItem({ event }: EventItemProps) {
"delta" in event &&
event.delta &&
event.delta.length > 100) ||
(event.type === "response.completed" &&
"response" in event &&
event.response) ||
// Make error events expandable to show full error details
event.type === "error";
@@ -626,9 +681,9 @@ function EventExpandedContent({
}
break;
case "response.function_result.complete":
if ("data" in event && event.data) {
const data = event.data as FunctionResultData;
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -636,57 +691,42 @@ function EventExpandedContent({
<span className="font-semibold text-sm">Function Result</span>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
{data.call_id && (
<div>
<span className="font-medium text-muted-foreground">
Call ID:
</span>
<span className="ml-2 font-mono text-xs">{data.call_id}</span>
</div>
)}
<div>
<span className="font-medium text-muted-foreground">
Call ID:
</span>
<span className="ml-2 font-mono text-xs">{result.call_id}</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Status:
</span>
<span
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${
data.status === "completed"
result.status === "completed"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
{data.status || "unknown"}
{result.status}
</span>
</div>
{data.result !== undefined && (
<div>
<span className="font-medium text-muted-foreground">
Result:
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
{typeof data.result === "string"
? data.result
: JSON.stringify(data.result, null, 1)}
</pre>
</div>
<div>
<span className="font-medium text-muted-foreground">
Output:
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
{result.output}
</pre>
</div>
)}
{data.exception !== null && data.exception !== undefined && (
<div>
<span className="font-medium text-destructive">Error:</span>
<div className="mt-1">
<pre className="text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all">
{data.exception}
</pre>
</div>
</div>
)}
</div>
</div>
</div>
);
}
break;
}
case "response.workflow_event.complete":
if ("data" in event && event.data) {
@@ -873,6 +913,74 @@ function EventExpandedContent({
}
break;
case "response.completed":
if ("response" in event && event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const response = completedEvent.response;
return (
<div className="space-y-2">
<div className="grid grid-cols-1 gap-2 text-xs">
{response.usage && (
<>
<div>
<span className="font-medium text-muted-foreground">
Usage:
</span>
</div>
<div className="ml-4 space-y-1">
<div>
<span className="font-medium text-muted-foreground">
Input tokens:
</span>
<span className="ml-2 font-mono">
{response.usage.input_tokens}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Output tokens:
</span>
<span className="ml-2 font-mono">
{response.usage.output_tokens}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Total tokens:
</span>
<span className="ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded">
{response.usage.total_tokens}
</span>
</div>
</div>
</>
)}
{response.id && (
<div>
<span className="font-medium text-muted-foreground">
Response ID:
</span>
<span className="ml-2 font-mono text-xs break-all">
{response.id}
</span>
</div>
)}
{response.model && (
<div>
<span className="font-medium text-muted-foreground">
Model:
</span>
<span className="ml-2 font-mono text-xs break-all">
{response.model}
</span>
</div>
)}
</div>
</div>
);
}
break;
default:
return (
<div className="text-xs text-muted-foreground">
@@ -978,7 +1086,7 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
</span>{" "}
or restart devui with the tracing flag{" "}
<div className="font-mono bg-accent/10 px-1 rounded">
devui --enable-tracing
devui --tracing
</div>
to enable tracing.
</div>
@@ -1198,21 +1306,15 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
(event) => event.type === "response.function_call.complete"
);
const functionResults = events.filter(
(event) => event.type === "response.function_result.complete"
(event) => getFunctionResultFromEvent(event) !== null
);
// Create a map of call_id to results for easy lookup
const resultsByCallId = new Map();
functionResults.forEach((result) => {
if (
"data" in result &&
result.data &&
(result.data as EventDataBase).call_id
) {
resultsByCallId.set(
String((result.data as EventDataBase).call_id),
result
);
const resultData = getFunctionResultFromEvent(result);
if (resultData) {
resultsByCallId.set(resultData.call_id, result);
}
});
@@ -1277,7 +1379,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
// Check if this is a function call event
const isFunctionCall = event.type === "response.function_call.complete";
const isFunctionResult = event.type === "response.function_result.complete";
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
if (!isFunctionCall && !isFunctionResult) {
return null;
@@ -0,0 +1,9 @@
/**
* Layout Components - Exports
*/
export { AppHeader } from "./app-header";
export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { AboutModal } from "./about-modal";
@@ -1,331 +0,0 @@
/**
* ContentRenderer - Renders individual content items based on type
*/
import { useState } from "react";
import {
Download,
FileText,
AlertCircle,
Code,
ChevronDown,
ChevronUp,
Music,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { RenderProps } from "./types";
import {
isTextContent,
isFunctionCallContent,
isFunctionResultContent,
} from "@/types/agent-framework";
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isTextContent(content)) return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH && !isStreaming;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
</div>
{isStreaming && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
{shouldTruncate && (
<div className="flex justify-end mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center gap-1 text-xs
bg-background/80 hover:bg-background border border-border/50 hover:border-border
text-muted-foreground hover:text-foreground
transition-colors cursor-pointer px-2 py-1 rounded"
>
{isExpanded ? (
<>
less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
)}
</div>
);
}
function DataContentRenderer({ content, className }: RenderProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "data") return null;
// Extract data URI and media type (updated for new field names)
const dataUri = typeof content.uri === "string" ? content.uri : "";
const mediaTypeMatch = dataUri.match(/^data:([^;]+)/);
const mediaType = content.media_type || mediaTypeMatch?.[1] || "unknown";
const isImage = mediaType.startsWith("image/");
const isPdf = mediaType === "application/pdf";
const isAudio = mediaType.startsWith("audio/");
if (isImage && !imageError) {
return (
<div className={`my-2 ${className || ""}`}>
<img
src={dataUri}
alt="Uploaded image"
className={`rounded-lg border max-w-full transition-all cursor-pointer ${
isExpanded ? "max-h-none" : "max-h-64"
}`}
onClick={() => setIsExpanded(!isExpanded)}
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
{mediaType} Click to {isExpanded ? "collapse" : "expand"}
</div>
</div>
);
}
if (isAudio) {
return (
<div className={`my-2 p-3 border rounded-lg bg-purple-50 dark:bg-purple-950/20 ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium text-purple-800 dark:text-purple-300">Audio File</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<audio controls className="w-full max-w-md">
<source src={dataUri} type={mediaType} />
Your browser does not support the audio element.
</audio>
</div>
);
}
// Fallback for non-images/non-audio or failed images
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
{isPdf ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<Download className="h-4 w-4" />
)}
<span className="text-sm font-medium">
{isPdf ? "PDF Document" : "File Attachment"}
</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => {
const link = document.createElement("a");
link.href = dataUri;
link.download = `attachment.${mediaType.split("/")[1] || "bin"}`;
link.click();
}}
>
<Download className="h-3 w-3 mr-1" />
Download
</Button>
</div>
);
}
function FunctionCallRenderer({ content, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isFunctionCallContent(content)) return null;
let parsedArgs;
try {
parsedArgs =
typeof content.arguments === "string"
? JSON.parse(content.arguments)
: content.arguments;
} catch {
parsedArgs = content.arguments;
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
Function Call: {content.name}
</span>
<span className="text-xs text-blue-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<div className="text-blue-600 mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
function FunctionResultRenderer({ content, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isFunctionResultContent(content)) return null;
return (
<div
className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}
>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Code className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">
Function Result
</span>
<span className="text-xs text-green-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">
{typeof content.result === "string"
? content.result
: JSON.stringify(content.result, null, 2)}
</pre>
</div>
)}
</div>
);
}
function ErrorContentRenderer({ content, className }: RenderProps) {
if (content.type !== "error") return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-red-50 ${className || ""}`}>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium text-red-800">Error</span>
{content.error_code && (
<span className="text-xs text-red-600">({content.error_code})</span>
)}
</div>
<div className="mt-1 text-sm text-red-700">{content.error}</div>
</div>
);
}
function UriContentRenderer({ content, className }: RenderProps) {
const [imageError, setImageError] = useState(false);
if (content.type !== "uri") return null;
const isImage = content.media_type?.startsWith("image/");
if (isImage && !imageError) {
return (
<div className={`my-2 ${className || ""}`}>
<img
src={content.uri}
alt="Referenced image"
className="rounded-lg border max-w-full max-h-64"
onError={() => setImageError(true)}
/>
<div className="text-xs text-muted-foreground mt-1">
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{content.uri}
</a>
</div>
</div>
);
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<a
href={content.uri}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline"
>
{content.media_type || "External Link"}
</a>
</div>
<div className="text-xs text-muted-foreground mt-1 break-all">
{content.uri}
</div>
</div>
);
}
export function ContentRenderer({
content,
isStreaming,
className,
}: RenderProps) {
switch (content.type) {
case "text":
return (
<TextContentRenderer
content={content}
isStreaming={isStreaming}
className={className}
/>
);
case "data":
return <DataContentRenderer content={content} className={className} />;
case "uri":
return <UriContentRenderer content={content} className={className} />;
case "function_call":
return <FunctionCallRenderer content={content} className={className} />;
case "function_result":
return <FunctionResultRenderer content={content} className={className} />;
case "error":
return <ErrorContentRenderer content={content} className={className} />;
default:
// Fallback for unsupported content types
return (
<div
className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}
>
<div>Unsupported content type: {content.type}</div>
<pre className="mt-1 text-xs whitespace-pre-wrap">
{JSON.stringify(content, null, 2)}
</pre>
</div>
);
}
}
@@ -1,38 +0,0 @@
/**
* MessageRenderer - Main orchestrator for rendering message contents
*/
import { StreamingRenderer } from "./StreamingRenderer";
import { ContentRenderer } from "./ContentRenderer";
import type { MessageRendererProps } from "./types";
export function MessageRenderer({
contents,
isStreaming = false,
className,
}: MessageRendererProps) {
// If not streaming, render each content item individually
if (!isStreaming) {
return (
<div className={className}>
{contents.map((content, index) => (
<ContentRenderer
key={index}
content={content}
isStreaming={false}
className={index > 0 ? "mt-2" : ""}
/>
))}
</div>
);
}
// For streaming, use the streaming renderer for smart accumulation
return (
<StreamingRenderer
contents={contents}
isStreaming={isStreaming}
className={className}
/>
);
}
@@ -1,114 +0,0 @@
/**
* StreamingRenderer - Handles accumulation and display of streaming content
*/
import { useState, useEffect } from "react";
import { ContentRenderer } from "./ContentRenderer";
import type { Contents, MessageRenderState } from "./types";
import { isTextContent } from "@/types/agent-framework";
interface StreamingRendererProps {
contents: Contents[];
isStreaming?: boolean;
className?: string;
}
export function StreamingRenderer({
contents,
isStreaming = false,
className,
}: StreamingRendererProps) {
const [renderState, setRenderState] = useState<MessageRenderState>({
textAccumulator: "",
dataContentItems: [],
functionCalls: [],
errors: [],
isComplete: !isStreaming,
});
useEffect(() => {
// Process and accumulate content
let textAccumulator = "";
const dataContentItems: Contents[] = [];
const functionCalls: Contents[] = [];
const errors: Contents[] = [];
contents.forEach((content) => {
if (isTextContent(content)) {
textAccumulator += content.text;
} else if (content.type === "data") {
// Only show data content when streaming is complete or item is complete
if (!isStreaming) {
dataContentItems.push(content);
}
} else if (content.type === "function_call") {
functionCalls.push(content);
} else if (content.type === "error") {
errors.push(content);
} else {
// Other content types (uri, function_result, etc.)
dataContentItems.push(content);
}
});
setRenderState({
textAccumulator,
dataContentItems,
functionCalls,
errors,
isComplete: !isStreaming,
});
}, [contents, isStreaming]);
const hasTextContent = renderState.textAccumulator.length > 0;
const hasOtherContent =
renderState.dataContentItems.length > 0 ||
renderState.functionCalls.length > 0 ||
renderState.errors.length > 0;
return (
<div className={className}>
{/* Render accumulated text with streaming indicator */}
{hasTextContent && (
<div className="whitespace-pre-wrap break-words">
{renderState.textAccumulator}
{isStreaming && hasTextContent && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
)}
{/* Render other content types when complete or non-data items immediately */}
{hasOtherContent && (
<div className="mt-2 space-y-2">
{renderState.errors.map((content, index) => (
<ContentRenderer key={`error-${index}`} content={content} />
))}
{renderState.functionCalls.map((content, index) => (
<ContentRenderer key={`function-${index}`} content={content} />
))}
{renderState.dataContentItems.map((content, index) => (
<ContentRenderer
key={`data-${index}`}
content={content}
isStreaming={isStreaming}
/>
))}
</div>
)}
{/* Show loading indicator when streaming and no text content yet */}
{isStreaming && !hasTextContent && !hasOtherContent && (
<div className="flex items-center space-x-1">
<div className="flex space-x-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
</div>
</div>
)}
</div>
);
}
@@ -1,8 +0,0 @@
/**
* Message Renderer - Exports
*/
export { MessageRenderer } from "./MessageRenderer";
export { ContentRenderer } from "./ContentRenderer";
export { StreamingRenderer } from "./StreamingRenderer";
export type { MessageRendererProps, RenderProps, MessageRenderState } from "./types";
@@ -1,48 +0,0 @@
/**
* Types for message rendering components
*/
// Re-export and extend types from agent-framework
import type {
Contents,
TextContent,
DataContent,
UriContent,
FunctionCallContent,
FunctionResultContent,
ErrorContent,
AgentRunResponseUpdate,
} from "@/types/agent-framework";
export type {
Contents,
TextContent,
DataContent,
UriContent,
FunctionCallContent,
FunctionResultContent,
ErrorContent,
AgentRunResponseUpdate,
};
// UI-specific types for message rendering
export interface MessageRenderState {
// Track accumulated content during streaming
textAccumulator: string;
dataContentItems: Contents[];
functionCalls: Contents[];
errors: Contents[];
isComplete: boolean;
}
export interface RenderProps {
content: Contents;
isStreaming?: boolean;
className?: string;
}
export interface MessageRendererProps {
contents: Contents[];
isStreaming?: boolean;
className?: string;
}
@@ -24,43 +24,13 @@ export interface SampleEntity {
export const SAMPLE_ENTITIES: SampleEntity[] = [
// Beginner Agents
{
id: "weather-agent",
name: "Weather Agent",
description:
"Simple weather agent with mock data demonstrating basic tool usage",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent/agent.py",
tags: ["openai", "tools", "basic"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Function calling",
"Mock weather data",
"Simple tool integration",
],
requiredEnvVars: [
{
name: "OPENAI_API_KEY",
description: "OpenAI API key for chat completions",
required: true,
},
{
name: "OPENAI_CHAT_MODEL_ID",
description: "OpenAI model ID (e.g., gpt-4o)",
required: false,
example: "gpt-4o",
},
],
},
{
id: "foundry-weather-agent",
name: "Azure AI Weather Agent",
description:
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/foundry_agent/agent.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/foundry_agent/agent.py",
tags: ["azure-ai", "foundry", "tools"],
author: "Microsoft",
difficulty: "beginner",
@@ -91,7 +61,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"Weather agent using Azure OpenAI with API key authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent_azure/agent.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/weather_agent_azure/agent.py",
tags: ["azure", "openai", "tools"],
author: "Microsoft",
difficulty: "beginner",
@@ -129,7 +99,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"5-step workflow demonstrating email spam detection with branching logic",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/spam_workflow/workflow.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/spam_workflow/workflow.py",
tags: ["workflow", "branching", "multi-step"],
author: "Microsoft",
difficulty: "beginner",
@@ -147,7 +117,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/fanout_workflow/workflow.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/fanout_workflow/workflow.py",
tags: ["workflow", "fan-out", "fan-in", "parallel"],
author: "Microsoft",
difficulty: "advanced",
@@ -6,10 +6,10 @@
import type {
AgentInfo,
AgentSource,
Conversation,
HealthResponse,
RunAgentRequest,
RunWorkflowRequest,
ThreadInfo,
WorkflowInfo,
} from "@/types";
import type { AgentFrameworkRequest } from "@/types/agent-framework";
@@ -45,23 +45,12 @@ interface DiscoveryResponse {
entities: BackendEntityInfo[];
}
interface ThreadApiResponse {
// Conversation API types (OpenAI standard)
interface ConversationApiResponse {
id: string;
object: "thread";
object: "conversation";
created_at: number;
metadata: { agent_id: string };
}
interface ThreadListResponse {
object: "list";
data: ThreadApiObject[];
}
interface ThreadApiObject {
id: string;
object: "thread";
agent_id: string;
created_at?: string;
metadata?: Record<string, string>;
}
const DEFAULT_API_BASE_URL =
@@ -217,36 +206,69 @@ class ApiClient {
);
}
// Thread management using real /v1/threads endpoints
async createThread(agentId: string): Promise<ThreadInfo> {
const response = await this.request<ThreadApiResponse>("/v1/threads", {
method: "POST",
body: JSON.stringify({ agent_id: agentId }),
});
// ========================================
// Conversation Management (OpenAI Standard)
// ========================================
async createConversation(
metadata?: Record<string, string>
): Promise<Conversation> {
const response = await this.request<ConversationApiResponse>(
"/v1/conversations",
{
method: "POST",
body: JSON.stringify({ metadata }),
}
);
return {
id: response.id,
agent_id: agentId,
created_at: new Date(response.created_at * 1000).toISOString(),
message_count: 0,
object: "conversation",
created_at: response.created_at,
metadata: response.metadata,
};
}
async getThreads(agentId: string): Promise<ThreadInfo[]> {
const response = await this.request<ThreadListResponse>(
`/v1/threads?agent_id=${agentId}`
);
return response.data.map((thread: ThreadApiObject) => ({
id: thread.id,
agent_id: thread.agent_id,
created_at: thread.created_at || new Date().toISOString(),
message_count: 0, // We don't track this yet
}));
async listConversations(
agentId?: string
): Promise<{ data: Conversation[]; has_more: boolean }> {
const url = agentId
? `/v1/conversations?agent_id=${encodeURIComponent(agentId)}`
: "/v1/conversations";
const response = await this.request<{
object: "list";
data: ConversationApiResponse[];
has_more: boolean;
}>(url);
return {
data: response.data.map((conv) => ({
id: conv.id,
object: "conversation",
created_at: conv.created_at,
metadata: conv.metadata,
})),
has_more: response.has_more,
};
}
async deleteThread(threadId: string): Promise<boolean> {
async getConversation(conversationId: string): Promise<Conversation> {
const response = await this.request<ConversationApiResponse>(
`/v1/conversations/${conversationId}`
);
return {
id: response.id,
object: "conversation",
created_at: response.created_at,
metadata: response.metadata,
};
}
async deleteConversation(conversationId: string): Promise<boolean> {
try {
await this.request(`/v1/threads/${threadId}`, {
await this.request(`/v1/conversations/${conversationId}`, {
method: "DELETE",
});
return true;
@@ -255,56 +277,35 @@ class ApiClient {
}
}
async getThreadMessages(
threadId: string
): Promise<import("@/types").ChatMessage[]> {
try {
const response = await this.request<{ data: unknown[] }>(
`/v1/threads/${threadId}/messages`
);
async listConversationItems(
conversationId: string,
options?: { limit?: number; after?: string; order?: "asc" | "desc" }
): Promise<{ data: unknown[]; has_more: boolean }> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", options.limit.toString());
if (options?.after) params.set("after", options.after);
if (options?.order) params.set("order", options.order);
// Convert API messages to ChatMessage format, handling missing fields
return response.data.map((msg: unknown, index: number) => {
const msgObj = msg as Record<string, unknown>;
const role = msgObj.role as string;
return {
id: (msgObj.message_id as string) || `restored-${index}`,
role:
role === "user" ||
role === "assistant" ||
role === "system" ||
role === "tool"
? role
: "user",
contents:
(msgObj.contents as import("@/types/agent-framework").Contents[]) ||
[],
timestamp: (msgObj.timestamp as string) || new Date().toISOString(),
author_name: msgObj.author_name as string | undefined,
message_id: msgObj.message_id as string | undefined,
};
});
} catch (error) {
console.error("Failed to get thread messages:", error);
return [];
}
const queryString = params.toString();
const url = `/v1/conversations/${conversationId}/items${
queryString ? `?${queryString}` : ""
}`;
return this.request<{ data: unknown[]; has_more: boolean }>(url);
}
// OpenAI-compatible streaming methods using /v1/responses endpoint
// Stream agent execution using pure OpenAI format
// Stream agent execution using OpenAI format with simplified routing
async *streamAgentExecutionOpenAI(
agentId: string,
request: RunAgentRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const openAIRequest: AgentFrameworkRequest = {
model: "agent-framework",
model: agentId, // Model IS the entity_id (simplified routing!)
input: request.input, // Direct OpenAI ResponseInputParam
stream: true,
extra_body: {
entity_id: agentId,
thread_id: request.thread_id,
},
conversation: request.conversation_id, // OpenAI standard conversation param
};
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest);
@@ -326,7 +327,19 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(`OpenAI streaming request failed: ${response.status}`);
// Try to extract detailed error message from response body
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = await response.json();
if (errorBody.error && errorBody.error.message) {
errorMessage = errorBody.error.message;
} else if (errorBody.detail) {
errorMessage = errorBody.detail;
}
} catch {
// Fallback to generic message if parsing fails
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
@@ -380,15 +393,14 @@ class ApiClient {
workflowId: string,
request: RunWorkflowRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Convert to OpenAI format
// Convert to OpenAI format - use model field for entity_id (same as agents)
const openAIRequest: AgentFrameworkRequest = {
model: "agent-framework", // Placeholder model name
input: "", // Empty string for workflows - actual data is in extra_body.input_data
model: workflowId, // Use workflow ID in model field (matches agent pattern)
input: typeof request.input_data === 'string'
? request.input_data
: JSON.stringify(request.input_data || ""), // Convert input_data to string
stream: true,
extra_body: {
entity_id: workflowId,
input_data: request.input_data, // Preserve structured data
},
conversation: request.conversation_id, // Include conversation if present
};
const response = await fetch(`${this.baseUrl}/v1/responses`, {
@@ -401,7 +413,19 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(`OpenAI streaming request failed: ${response.status}`);
// Try to extract detailed error message from response body
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = await response.json();
if (errorBody.error && errorBody.error.message) {
errorMessage = errorBody.error.message;
} else if (errorBody.detail) {
errorMessage = errorBody.detail;
}
} catch {
// Fallback to generic message if parsing fails
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
@@ -457,7 +481,7 @@ class ApiClient {
agentId: string,
request: RunAgentRequest
): Promise<{
thread_id: string;
conversation_id: string;
result: unknown[];
message_count: number;
}> {
@@ -34,10 +34,27 @@ export interface ResponseInputFileParam {
filename: string;
}
// DevUI Extension: Function Approval Response Input
export interface ResponseInputFunctionApprovalParam {
/** The type of the input item. Always `function_approval_response`. */
type: "function_approval_response";
/** The ID of the approval request being responded to. */
request_id: string;
/** Whether the function call is approved. */
approved: boolean;
/** The function call being approved/rejected. */
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
export type ResponseInputContent =
| ResponseInputTextParam
| ResponseInputImageParam
| ResponseInputFileParam;
| ResponseInputFileParam
| ResponseInputFunctionApprovalParam;
export interface EasyInputMessage {
type?: "message";
@@ -51,7 +68,6 @@ export type ResponseInputParam = ResponseInputItem[];
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
export interface AgentFrameworkExtraBody {
entity_id: string;
thread_id?: string;
input_data?: Record<string, unknown>;
}
@@ -61,6 +77,9 @@ export interface AgentFrameworkRequest {
input: string | ResponseInputParam; // Union type matching OpenAI
stream?: boolean;
// OpenAI conversation parameter (standard!)
conversation?: string | { id: string };
// Common OpenAI optional fields
instructions?: string;
metadata?: Record<string, unknown>;
@@ -230,7 +249,9 @@ export interface ChatResponseUpdate {
raw_representation?: unknown;
}
// Agent thread
// Agent thread (internal AgentFramework type - not exposed via DevUI API)
// Note: DevUI uses OpenAI Conversations API. This type represents the internal
// AgentThread used by the framework for execution, wrapped by ConversationStore.
export interface AgentThread {
service_thread_id?: string;
message_store?: unknown; // ChatMessageStore - could be typed further if needed
@@ -72,28 +72,22 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
start_executor_id: string; // Entry point executor ID
}
export interface ThreadInfo {
// OpenAI Conversations API (standard)
export interface Conversation {
id: string;
agent_id: string;
created_at: string;
message_count: number;
}
export interface SessionInfo {
thread_id: string;
agent_id: string;
created_at: string;
messages: Array<Record<string, unknown>>;
metadata: Record<string, unknown>;
object: "conversation";
created_at: number;
metadata?: Record<string, string>;
}
export interface RunAgentRequest {
input: import("./agent-framework").ResponseInputParam;
thread_id?: string;
conversation_id?: string; // OpenAI standard conversation parameter
}
export interface RunWorkflowRequest {
input_data: Record<string, unknown>;
conversation_id?: string;
}
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
@@ -107,9 +101,10 @@ export type {
// New structured event types
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseFunctionResultComplete,
ResponseTraceEventComplete,
ResponseUsageEventComplete,
ResponseOutputItemAddedEvent,
ResponseFunctionResultComplete,
ResponseCompletedEvent,
StructuredEvent,
} from "./openai";
@@ -149,7 +144,7 @@ export interface ChatMessage {
// UI State types
export interface AppState {
selectedAgent?: AgentInfo | WorkflowInfo;
currentThread?: ThreadInfo;
currentConversation?: Conversation;
agents: AgentInfo[];
workflows: WorkflowInfo[];
isLoading: boolean;
@@ -161,3 +156,13 @@ export interface ChatState {
isStreaming: boolean;
// streamEvents removed - use OpenAI events directly instead
}
// DevUI-specific: Pending approval state
export interface PendingApproval {
request_id: string;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
@@ -36,32 +36,13 @@ export interface ResponseWorkflowEventComplete {
sequence_number: number;
}
// Custom DevUI: Function result event
// This is a DevUI extension - OpenAI doesn't stream function execution results
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
data: {
call_id: string;
result: unknown;
status: "completed" | "failed";
exception?: string;
timestamp: string;
};
call_id: string;
item_id: string;
output_index: number;
sequence_number: number;
}
// Removed - using ResponseTraceEventComplete defined below
export interface ResponseUsageEventComplete {
type: "response.usage.complete";
data: {
usage_data: Record<string, unknown>;
total_tokens: number;
completion_tokens: number;
prompt_tokens: number;
timestamp: string;
};
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
@@ -103,6 +84,25 @@ export interface ResponseFunctionCallArgumentsDelta {
sequence_number?: number;
}
// OpenAI Responses API - Function Tool Call Item
export interface ResponseFunctionToolCall {
id: string; // Item ID
call_id: string; // Call ID for pairing with results
name: string; // Function name
arguments: string; // JSON arguments
type: "function_call";
status?: "in_progress" | "completed" | "incomplete";
}
// OpenAI Responses API - Output Item Added Event
// OpenAI standard: Output item added event
export interface ResponseOutputItemAddedEvent {
type: "response.output_item.added";
item: ResponseFunctionToolCall;
output_index: number;
sequence_number: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace_event.complete";
@@ -150,17 +150,43 @@ export interface ResponseErrorEvent extends ResponseStreamEvent {
sequence_number: number;
}
// DevUI Extension: Function Approval Events
export interface ResponseFunctionApprovalRequestedEvent {
type: "response.function_approval.requested";
request_id: string;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
item_id: string;
output_index: number;
sequence_number: number;
}
export interface ResponseFunctionApprovalRespondedEvent {
type: "response.function_approval.responded";
request_id: string;
approved: boolean;
item_id: string;
output_index: number;
sequence_number: number;
}
// Union type for all structured events
export type StructuredEvent =
| ResponseCompletedEvent
| ResponseWorkflowEventComplete
| ResponseFunctionResultComplete
| ResponseTraceEventComplete
| ResponseTraceComplete
| ResponseUsageEventComplete
| ResponseOutputItemAddedEvent
| ResponseFunctionResultComplete
| ResponseFunctionCallComplete
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseErrorEvent;
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent;
// Extended stream event that includes our structured events
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
@@ -215,6 +241,13 @@ export interface ResponseUsage {
};
}
// OpenAI standard: response.completed event
export interface ResponseCompletedEvent {
type: "response.completed";
response: OpenAIResponse;
sequence_number: number;
}
// Request format for Agent Framework
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
@@ -226,3 +259,100 @@ export interface OpenAIError {
code?: string;
};
}
// ============================================================================
// OpenAI Conversations API Types - for conversation history
// ============================================================================
// Message content types (what goes inside Message.content[])
export interface MessageTextContent {
type: "text";
text: string;
}
export interface MessageInputImage {
type: "input_image";
image_url: string;
detail?: "low" | "high" | "auto";
file_id?: string;
}
export interface MessageInputFile {
type: "input_file";
file_url?: string;
file_data?: string;
file_id?: string;
filename?: string;
}
// DevUI Extension: Function approval response content
export interface MessageFunctionApprovalResponseContent {
type: "function_approval_response";
request_id: string;
approved: boolean;
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
export type MessageContent =
| MessageTextContent
| MessageInputImage
| MessageInputFile
| MessageFunctionApprovalResponseContent;
// Message item (user/assistant messages with content)
export interface ConversationMessage {
id: string;
type: "message";
role: "user" | "assistant" | "system" | "tool";
content: MessageContent[];
status: "in_progress" | "completed" | "incomplete";
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
}
// Function call item (separate from message)
export interface ConversationFunctionCall {
id: string;
type: "function_call";
call_id: string;
name: string;
arguments: string;
status: "in_progress" | "completed" | "incomplete";
}
// Function call output item
export interface ConversationFunctionCallOutput {
id: string;
type: "function_call_output";
call_id: string;
output: string;
status?: "in_progress" | "completed" | "incomplete";
}
// Union of all conversation item types
export type ConversationItem =
| ConversationMessage
| ConversationFunctionCall
| ConversationFunctionCallOutput;
// Conversation metadata
export interface Conversation {
id: string;
object: "conversation";
created_at: number;
metadata?: Record<string, string>;
}
// List response
export interface ConversationItemsListResponse {
object: "list";
data: ConversationItem[];
has_more: boolean;
}
@@ -33,6 +33,14 @@ export interface WorkflowExecutor extends Executor {
workflow: Workflow; // Nested workflow
}
export interface MagenticOrchestratorExecutor extends Executor {
type: "MagenticOrchestratorExecutor";
}
export interface MagenticAgentExecutor extends Executor {
type: "MagenticAgentExecutor";
}
/**
* Edge interface that mirrors agent_framework_workflow._edge.Edge
*/
@@ -1,5 +1,5 @@
import type { Node, Edge } from "@xyflow/react";
import type { ExecutorNodeData } from "@/components/workflow/executor-node";
import type { ExecutorNodeData } from "@/components/features/workflow/executor-node";
/**
* Lightweight auto-layout algorithm to replace dagre
@@ -3,7 +3,7 @@ import type { Node, Edge } from "@xyflow/react";
import type {
ExecutorNodeData,
ExecutorState,
} from "@/components/workflow/executor-node";
} from "@/components/features/workflow/executor-node";
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
@@ -309,9 +309,11 @@ export function applyDagreLayout(
* Process workflow events and extract node updates
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[]
events: ExtendedResponseStreamEvent[],
startExecutorId?: string
): Record<string, NodeUpdate> {
const nodeUpdates: Record<string, NodeUpdate> = {};
let hasWorkflowStarted = false;
events.forEach((event) => {
if (
@@ -343,6 +345,9 @@ export function processWorkflowEvents(
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
state = "completed";
} else if (eventType === "WorkflowStartedEvent") {
// Mark that workflow has started - we'll set start node to running
hasWorkflowStarted = true;
}
// Update the node state (keep most recent update per executor)
@@ -358,6 +363,18 @@ export function processWorkflowEvents(
}
});
// If workflow has started and we have a start executor, set it to running
// (unless it already has a specific state from an ExecutorInvokedEvent)
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
return nodeUpdates;
}
+38
View File
@@ -0,0 +1,38 @@
# DevUI Samples - Moved
**The DevUI samples have been relocated to the main samples folder for better consistency and discoverability.**
## New Location
All DevUI samples are now located at:
```
python/samples/getting_started/devui/
```
## Available Samples
- **weather_agent** - Basic OpenAI weather agent
- **weather_agent_azure** - Azure OpenAI weather agent
- **foundry_agent** - Azure AI Foundry weather agent
- **spam_workflow** - Email spam detection workflow
- **fanout_workflow** - Complex fan-in/fan-out data processing workflow
- **in_memory_mode.py** - In-memory entity registration example
## Quick Start
```bash
cd ../../samples/getting_started/devui
python in_memory_mode.py
```
Or for directory discovery:
```bash
cd ../../samples/getting_started/devui
devui
```
## Learn More
See the [DevUI samples README](../../../samples/getting_started/devui/README.md) for detailed documentation.
@@ -1,3 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Fanout workflow example."""
@@ -1,700 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Complex Fan-In/Fan-Out Data Processing Workflow.
This workflow demonstrates a sophisticated data processing pipeline with multiple stages:
1. Data Ingestion - Simulates loading data from multiple sources
2. Data Validation - Multiple validators run in parallel to check data quality
3. Data Transformation - Fan-out to different transformation processors
4. Quality Assurance - Multiple QA checks run in parallel
5. Data Aggregation - Fan-in to combine processed results
6. Final Processing - Generate reports and complete workflow
The workflow includes realistic delays to simulate actual processing time and
shows complex fan-in/fan-out patterns with conditional processing.
"""
import asyncio
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Literal
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
handler,
)
from pydantic import BaseModel, Field
from typing_extensions import Never
class DataType(Enum):
"""Types of data being processed."""
CUSTOMER = "customer"
TRANSACTION = "transaction"
PRODUCT = "product"
ANALYTICS = "analytics"
class ValidationResult(Enum):
"""Results of data validation."""
VALID = "valid"
WARNING = "warning"
ERROR = "error"
class ProcessingRequest(BaseModel):
"""Complex input structure for data processing workflow."""
# Basic information
data_source: Literal["database", "api", "file_upload", "streaming"] = Field(
description="The source of the data to be processed", default="database"
)
data_type: Literal["customer", "transaction", "product", "analytics"] = Field(
description="Type of data being processed", default="customer"
)
processing_priority: Literal["low", "normal", "high", "critical"] = Field(
description="Processing priority level", default="normal"
)
# Processing configuration
batch_size: int = Field(description="Number of records to process in each batch", default=500, ge=100, le=10000)
quality_threshold: float = Field(
description="Minimum quality score required (0.0-1.0)", default=0.8, ge=0.0, le=1.0
)
# Validation settings
enable_schema_validation: bool = Field(description="Enable schema validation checks", default=True)
enable_security_validation: bool = Field(description="Enable security validation checks", default=True)
enable_quality_validation: bool = Field(description="Enable data quality validation checks", default=True)
# Transformation options
transformations: list[Literal["normalize", "enrich", "aggregate"]] = Field(
description="List of transformations to apply", default=["normalize", "enrich"]
)
# Optional description
description: str | None = Field(description="Optional description of the processing request", default=None)
# Test failure scenarios
force_validation_failure: bool = Field(
description="Force validation failure for testing (demo purposes)", default=False
)
force_transformation_failure: bool = Field(
description="Force transformation failure for testing (demo purposes)", default=False
)
@dataclass
class DataBatch:
"""Represents a batch of data being processed."""
batch_id: str
data_type: DataType
size: int
content: str
source: str = "unknown"
timestamp: float = 0.0
@dataclass
class ValidationReport:
"""Report from data validation."""
batch_id: str
validator_id: str
result: ValidationResult
issues_found: int
processing_time: float
details: str
@dataclass
class TransformationResult:
"""Result from data transformation."""
batch_id: str
transformer_id: str
original_size: int
processed_size: int
transformation_type: str
processing_time: float
success: bool
@dataclass
class QualityAssessment:
"""Quality assessment result."""
batch_id: str
assessor_id: str
quality_score: float
recommendations: list[str]
processing_time: float
@dataclass
class ProcessingSummary:
"""Summary of all processing stages."""
batch_id: str
total_processing_time: float
validation_reports: list[ValidationReport]
transformation_results: list[TransformationResult]
quality_assessments: list[QualityAssessment]
final_status: str
# Data Ingestion Stage
class DataIngestion(Executor):
"""Simulates ingesting data from multiple sources with delays."""
@handler
async def ingest_data(self, request: ProcessingRequest, ctx: WorkflowContext[DataBatch]) -> None:
"""Simulate data ingestion with realistic delays based on input configuration."""
# Simulate network delay based on data source
delay_map = {"database": 1.5, "api": 3.0, "file_upload": 4.0, "streaming": 1.0}
delay = delay_map.get(request.data_source, 3.0)
await asyncio.sleep(delay) # Fixed delay for demo
# Simulate data size based on priority and configuration
base_size = request.batch_size
if request.processing_priority == "critical":
size_multiplier = 1.7 # Critical priority gets the largest batches
elif request.processing_priority == "high":
size_multiplier = 1.3 # High priority gets larger batches
elif request.processing_priority == "low":
size_multiplier = 0.6 # Low priority gets smaller batches
else: # normal
size_multiplier = 1.0 # Normal priority uses base size
actual_size = int(base_size * size_multiplier)
batch = DataBatch(
batch_id=f"batch_{5555}", # Fixed batch ID for demo
data_type=DataType(request.data_type),
size=actual_size,
content=f"Processing {request.data_type} data from {request.data_source}",
source=request.data_source,
timestamp=asyncio.get_event_loop().time(),
)
# Store both batch data and original request in shared state
await ctx.set_shared_state(f"batch_{batch.batch_id}", batch)
await ctx.set_shared_state(f"request_{batch.batch_id}", request)
await ctx.send_message(batch)
# Validation Stage (Fan-out)
class SchemaValidator(Executor):
"""Validates data schema and structure."""
@handler
async def validate_schema(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform schema validation with processing delay."""
# Check if schema validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_schema_validation:
return
# Simulate schema validation processing
processing_time = 2.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate validation results - consider force failure flag
issues = 4 if request.force_validation_failure else 2 # Fixed issue counts
result = (
ValidationResult.VALID
if issues <= 1
else (ValidationResult.WARNING if issues <= 2 else ValidationResult.ERROR)
)
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Schema validation found {issues} issues in {batch.data_type.value} data from {batch.source}",
)
await ctx.send_message(report)
class DataQualityValidator(Executor):
"""Validates data quality and completeness."""
@handler
async def validate_quality(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform data quality validation."""
# Check if quality validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_quality_validation:
return
processing_time = 2.5 # Fixed processing time
await asyncio.sleep(processing_time)
# Quality checks are stricter for higher priority data
issues = (
2 # Fixed issue count for high priority
if request.processing_priority in ["critical", "high"]
else 3 # Fixed issue count for normal priority
)
if request.force_validation_failure:
issues = max(issues, 4) # Ensure failure
result = (
ValidationResult.VALID
if issues <= 1
else (ValidationResult.WARNING if issues <= 3 else ValidationResult.ERROR)
)
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Quality check found {issues} data quality issues (priority: {request.processing_priority})",
)
await ctx.send_message(report)
class SecurityValidator(Executor):
"""Validates data for security and compliance issues."""
@handler
async def validate_security(self, batch: DataBatch, ctx: WorkflowContext[ValidationReport]) -> None:
"""Perform security validation."""
# Check if security validation is enabled
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
if not request or not request.enable_security_validation:
return
processing_time = 3.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Security is more stringent for customer/transaction data
issues = 1 if batch.data_type in [DataType.CUSTOMER, DataType.TRANSACTION] else 2
if request.force_validation_failure:
issues = max(issues, 1) # Force at least one security issue
# Security errors are more serious - less tolerance
result = ValidationResult.VALID if issues == 0 else ValidationResult.ERROR
report = ValidationReport(
batch_id=batch.batch_id,
validator_id=self.id,
result=result,
issues_found=issues,
processing_time=processing_time,
details=f"Security scan found {issues} security issues in {batch.data_type.value} data",
)
await ctx.send_message(report)
# Validation Aggregator (Fan-in)
class ValidationAggregator(Executor):
"""Aggregates validation results and decides on next steps."""
@handler
async def aggregate_validations(
self, reports: list[ValidationReport], ctx: WorkflowContext[DataBatch, str]
) -> None:
"""Aggregate all validation reports and make processing decision."""
if not reports:
return
batch_id = reports[0].batch_id
request = await ctx.get_shared_state(f"request_{batch_id}")
await asyncio.sleep(1) # Aggregation processing time
total_issues = sum(report.issues_found for report in reports)
has_errors = any(report.result == ValidationResult.ERROR for report in reports)
# Calculate quality score (0.0 to 1.0)
max_possible_issues = len(reports) * 5 # Assume max 5 issues per validator
quality_score = max(0.0, 1.0 - (total_issues / max_possible_issues))
# Decision logic: fail if errors OR quality below threshold
should_fail = has_errors or (quality_score < request.quality_threshold)
if should_fail:
failure_reason = []
if has_errors:
failure_reason.append("validation errors detected")
if quality_score < request.quality_threshold:
failure_reason.append(
f"quality score {quality_score:.2f} below threshold {request.quality_threshold:.2f}"
)
reason = " and ".join(failure_reason)
await ctx.yield_output(
f"Batch {batch_id} failed validation: {reason}. "
f"Total issues: {total_issues}, Quality score: {quality_score:.2f}"
)
return
# Retrieve original batch from shared state
batch_data = await ctx.get_shared_state(f"batch_{batch_id}")
if batch_data:
await ctx.send_message(batch_data)
else:
# Fallback: create a simplified batch
batch = DataBatch(
batch_id=batch_id,
data_type=DataType.ANALYTICS,
size=500,
content="Validated data ready for transformation",
)
await ctx.send_message(batch)
# Transformation Stage (Fan-out)
class DataNormalizer(Executor):
"""Normalizes and cleans data."""
@handler
async def normalize_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data normalization."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if normalization is enabled
if not request or "normalize" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="normalization",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 4.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate data size change during normalization
processed_size = int(batch.size * 1.0) # No size change for demo
# Consider force failure flag
success = not request.force_transformation_failure # 75% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="normalization",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
class DataEnrichment(Executor):
"""Enriches data with additional information."""
@handler
async def enrich_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data enrichment."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if enrichment is enabled
if not request or "enrich" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="enrichment",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 5.0 # Fixed processing time
await asyncio.sleep(processing_time)
processed_size = int(batch.size * 1.3) # Enrichment increases data
# Consider force failure flag
success = not request.force_transformation_failure # 67% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="enrichment",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
class DataAggregator(Executor):
"""Aggregates and summarizes data."""
@handler
async def aggregate_data(self, batch: DataBatch, ctx: WorkflowContext[TransformationResult]) -> None:
"""Perform data aggregation."""
request = await ctx.get_shared_state(f"request_{batch.batch_id}")
# Check if aggregation is enabled
if not request or "aggregate" not in request.transformations:
# Send a "skipped" result
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=batch.size,
transformation_type="aggregation",
processing_time=0.1,
success=True, # Consider skipped as successful
)
await ctx.send_message(result)
return
processing_time = 2.5 # Fixed processing time
await asyncio.sleep(processing_time)
processed_size = int(batch.size * 0.5) # Aggregation reduces data
# Consider force failure flag
success = not request.force_transformation_failure # 80% success rate simplified to always success
result = TransformationResult(
batch_id=batch.batch_id,
transformer_id=self.id,
original_size=batch.size,
processed_size=processed_size,
transformation_type="aggregation",
processing_time=processing_time,
success=success,
)
await ctx.send_message(result)
# Quality Assurance Stage (Fan-out)
class PerformanceAssessor(Executor):
"""Assesses performance characteristics of processed data."""
@handler
async def assess_performance(
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
) -> None:
"""Assess performance of transformations."""
if not results:
return
batch_id = results[0].batch_id
processing_time = 2.0 # Fixed processing time
await asyncio.sleep(processing_time)
avg_processing_time = sum(r.processing_time for r in results) / len(results)
success_rate = sum(1 for r in results if r.success) / len(results)
quality_score = (success_rate * 0.7 + (1 - min(avg_processing_time / 10, 1)) * 0.3) * 100
recommendations = []
if success_rate < 0.8:
recommendations.append("Consider improving transformation reliability")
if avg_processing_time > 5:
recommendations.append("Optimize processing performance")
if quality_score < 70:
recommendations.append("Review overall data pipeline efficiency")
assessment = QualityAssessment(
batch_id=batch_id,
assessor_id=self.id,
quality_score=quality_score,
recommendations=recommendations,
processing_time=processing_time,
)
await ctx.send_message(assessment)
class AccuracyAssessor(Executor):
"""Assesses accuracy and correctness of processed data."""
@handler
async def assess_accuracy(
self, results: list[TransformationResult], ctx: WorkflowContext[QualityAssessment]
) -> None:
"""Assess accuracy of transformations."""
if not results:
return
batch_id = results[0].batch_id
processing_time = 3.0 # Fixed processing time
await asyncio.sleep(processing_time)
# Simulate accuracy analysis
accuracy_score = 85.0 # Fixed accuracy score
recommendations = []
if accuracy_score < 85:
recommendations.append("Review data transformation algorithms")
if accuracy_score < 80:
recommendations.append("Implement additional validation steps")
assessment = QualityAssessment(
batch_id=batch_id,
assessor_id=self.id,
quality_score=accuracy_score,
recommendations=recommendations,
processing_time=processing_time,
)
await ctx.send_message(assessment)
# Final Processing and Completion
class FinalProcessor(Executor):
"""Final processing stage that combines all results."""
@handler
async def process_final_results(
self, assessments: list[QualityAssessment], ctx: WorkflowContext[Never, str]
) -> None:
"""Generate final processing summary and complete workflow."""
if not assessments:
await ctx.yield_output("No quality assessments received")
return
batch_id = assessments[0].batch_id
# Simulate final processing delay
await asyncio.sleep(2)
# Calculate overall metrics
avg_quality_score = sum(a.quality_score for a in assessments) / len(assessments)
total_recommendations = sum(len(a.recommendations) for a in assessments)
total_processing_time = sum(a.processing_time for a in assessments)
# Determine final status
if avg_quality_score >= 85:
final_status = "EXCELLENT"
elif avg_quality_score >= 75:
final_status = "GOOD"
elif avg_quality_score >= 65:
final_status = "ACCEPTABLE"
else:
final_status = "NEEDS_IMPROVEMENT"
completion_message = (
f"Batch {batch_id} processing completed!\n"
f"📊 Overall Quality Score: {avg_quality_score:.1f}%\n"
f"⏱️ Total Processing Time: {total_processing_time:.1f}s\n"
f"💡 Total Recommendations: {total_recommendations}\n"
f"🎖️ Final Status: {final_status}"
)
await ctx.yield_output(completion_message)
# Workflow Builder Helper
class WorkflowSetupHelper:
"""Helper class to set up the complex workflow with shared state management."""
@staticmethod
async def store_batch_data(batch: DataBatch, ctx: WorkflowContext) -> None:
"""Store batch data in shared state for later retrieval."""
await ctx.set_shared_state(f"batch_{batch.batch_id}", batch)
# Create the workflow instance
def create_complex_workflow():
"""Create the complex fan-in/fan-out workflow."""
# Create all executors
data_ingestion = DataIngestion(id="data_ingestion")
# Validation stage (fan-out)
schema_validator = SchemaValidator(id="schema_validator")
quality_validator = DataQualityValidator(id="quality_validator")
security_validator = SecurityValidator(id="security_validator")
validation_aggregator = ValidationAggregator(id="validation_aggregator")
# Transformation stage (fan-out)
data_normalizer = DataNormalizer(id="data_normalizer")
data_enrichment = DataEnrichment(id="data_enrichment")
data_aggregator_exec = DataAggregator(id="data_aggregator")
# Quality assurance stage (fan-out)
performance_assessor = PerformanceAssessor(id="performance_assessor")
accuracy_assessor = AccuracyAssessor(id="accuracy_assessor")
# Final processing
final_processor = FinalProcessor(id="final_processor")
# Build the workflow with complex fan-in/fan-out patterns
return (
WorkflowBuilder()
.set_start_executor(data_ingestion)
# Fan-out to validation stage
.add_fan_out_edges(data_ingestion, [schema_validator, quality_validator, security_validator])
# Fan-in from validation to aggregator
.add_fan_in_edges([schema_validator, quality_validator, security_validator], validation_aggregator)
# Fan-out to transformation stage
.add_fan_out_edges(validation_aggregator, [data_normalizer, data_enrichment, data_aggregator_exec])
# Fan-in to quality assurance stage (both assessors receive all transformation results)
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], performance_assessor)
.add_fan_in_edges([data_normalizer, data_enrichment, data_aggregator_exec], accuracy_assessor)
# Fan-in to final processor
.add_fan_in_edges([performance_assessor, accuracy_assessor], final_processor)
.build()
)
# Export the workflow for DevUI discovery
workflow = create_complex_workflow()
def main():
"""Launch the fanout workflow in DevUI."""
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Complex Fan-In/Fan-Out Data Processing Workflow")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: workflow_complex_workflow")
# Launch server with the workflow
serve(entities=[workflow], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -1,81 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry-based weather agent for Agent Framework Debug UI.
This agent uses Azure AI Foundry with Azure CLI authentication.
Make sure to run 'az login' before starting devui.
"""
import os
from typing import Annotated
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 22
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[day % len(conditions)]
temp = 18 + day
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
credential = AzureCliCredential()
# Cleanup will happen when Python process exits
client = AzureAIAgentClient(
async_credential=credential,
project_endpoint=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"),
model_deployment_name=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"),
)
# Agent instance following Agent Framework conventions
agent = client.create_agent(
name="FoundryWeatherAgent",
description="A helpful agent using Azure AI Foundry that provides weather information",
instructions="""
You are a weather assistant using Azure AI Foundry models. You can provide
current weather information and forecasts for any location. Always be helpful
and provide detailed weather information when asked.
""",
tools=[get_weather, get_forecast],
)
def main():
"""Launch the Foundry weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Foundry Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_FoundryWeatherAgent")
logger.info("Note: Make sure 'az login' has been run for authentication")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,71 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example of using Agent Framework DevUI with in-memory agent registration.
This demonstrates the simplest way to serve agents as OpenAI-compatible API endpoints.
"""
import logging
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework.devui import serve
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_time(
timezone: Annotated[str, "The timezone to get time for."] = "UTC",
) -> str:
"""Get current time for a timezone."""
from datetime import datetime
# Simplified for example
return f"Current time in {timezone}: {datetime.now().strftime('%H:%M:%S')}"
def main():
"""Main function demonstrating in-memory agent registration."""
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
# Create agents in code
weather_agent = ChatAgent(
name="weather-assistant",
description="Provides weather information and time",
instructions=(
"You are a helpful weather and time assistant. Use the available tools to "
"provide accurate weather information and current time for any location."
),
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
tools=[get_weather, get_time],
)
simple_agent = ChatAgent(
name="general-assistant",
description="A simple conversational agent",
instructions="You are a helpful assistant.",
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
)
# Collect entities for serving
entities = [weather_agent, simple_agent]
logger.info("Starting DevUI on http://localhost:8090")
logger.info("Entity IDs: agent_weather-assistant, agent_general-assistant")
# Launch server with auto-generated entity IDs
serve(entities=entities, port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Spam detection workflow sample for DevUI testing."""
from .workflow import workflow
__all__ = ["workflow"]
@@ -1,333 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Spam Detection Workflow Sample for DevUI.
The following sample demonstrates a comprehensive 5-step workflow with multiple executors
that process, analyze, detect spam, and handle email messages. This workflow illustrates
complex branching logic and realistic processing delays to demonstrate the workflow framework.
Workflow Steps:
1. Email Preprocessor - Cleans and prepares the email
2. Content Analyzer - Analyzes email content and structure
3. Spam Detector - Determines if the message is spam
4a. Spam Handler - Processes spam messages (quarantine, log, remove)
4b. Message Responder - Handles legitimate messages (validate, respond)
5. Final Processor - Completes the workflow with logging and cleanup
"""
import asyncio
import logging
from dataclasses import dataclass
from agent_framework import (
Case,
Default,
Executor,
WorkflowBuilder,
WorkflowContext,
handler,
)
from pydantic import BaseModel, Field
from typing_extensions import Never
@dataclass
class EmailContent:
"""A data class to hold the processed email content."""
original_message: str
cleaned_message: str
word_count: int
has_suspicious_patterns: bool = False
@dataclass
class ContentAnalysis:
"""A data class to hold content analysis results."""
email_content: EmailContent
sentiment_score: float
contains_links: bool
has_attachments: bool
risk_indicators: list[str]
@dataclass
class SpamDetectorResponse:
"""A data class to hold the spam detection results."""
analysis: ContentAnalysis
is_spam: bool = False
confidence_score: float = 0.0
spam_reasons: list[str] | None = None
def __post_init__(self):
"""Initialize spam_reasons list if None."""
if self.spam_reasons is None:
self.spam_reasons = []
@dataclass
class ProcessingResult:
"""A data class to hold the final processing result."""
original_message: str
action_taken: str
processing_time: float
status: str
is_spam: bool
confidence_score: float
spam_reasons: list[str]
class EmailRequest(BaseModel):
"""Request model for email processing."""
email: str = Field(
description="The email message to be processed.",
default="Hi there, are you interested in our new urgent offer today? Click here!",
)
class EmailPreprocessor(Executor):
"""Step 1: An executor that preprocesses and cleans email content."""
@handler
async def handle_email(self, email: EmailRequest, ctx: WorkflowContext[EmailContent]) -> None:
"""Clean and preprocess the email message."""
await asyncio.sleep(1.5) # Simulate preprocessing time
# Simulate email cleaning
cleaned = email.email.strip().lower()
word_count = len(email.email.split())
# Check for suspicious patterns
suspicious_patterns = ["urgent", "limited time", "act now", "free money"]
has_suspicious = any(pattern in cleaned for pattern in suspicious_patterns)
result = EmailContent(
original_message=email.email,
cleaned_message=cleaned,
word_count=word_count,
has_suspicious_patterns=has_suspicious,
)
await ctx.send_message(result)
class ContentAnalyzer(Executor):
"""Step 2: An executor that analyzes email content and structure."""
@handler
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[ContentAnalysis]) -> None:
"""Analyze the email content for various indicators."""
await asyncio.sleep(2.0) # Simulate analysis time
# Simulate content analysis
sentiment_score = 0.5 if email_content.has_suspicious_patterns else 0.8
contains_links = "http" in email_content.cleaned_message or "www" in email_content.cleaned_message
has_attachments = "attachment" in email_content.cleaned_message
# Build risk indicators
risk_indicators = []
if email_content.has_suspicious_patterns:
risk_indicators.append("suspicious_language")
if contains_links:
risk_indicators.append("contains_links")
if has_attachments:
risk_indicators.append("has_attachments")
if email_content.word_count < 10:
risk_indicators.append("too_short")
analysis = ContentAnalysis(
email_content=email_content,
sentiment_score=sentiment_score,
contains_links=contains_links,
has_attachments=has_attachments,
risk_indicators=risk_indicators,
)
await ctx.send_message(analysis)
class SpamDetector(Executor):
"""Step 3: An executor that determines if a message is spam based on analysis."""
def __init__(self, spam_keywords: list[str], id: str):
"""Initialize the executor with spam keywords."""
super().__init__(id=id)
self._spam_keywords = spam_keywords
@handler
async def handle_analysis(self, analysis: ContentAnalysis, ctx: WorkflowContext[SpamDetectorResponse]) -> None:
"""Determine if the message is spam based on content analysis."""
await asyncio.sleep(1.8) # Simulate detection time
# Check for spam keywords
email_text = analysis.email_content.cleaned_message
keyword_matches = [kw for kw in self._spam_keywords if kw in email_text]
# Calculate spam probability
spam_score = 0.0
spam_reasons = []
if keyword_matches:
spam_score += 0.4
spam_reasons.append(f"spam_keywords: {keyword_matches}")
if analysis.email_content.has_suspicious_patterns:
spam_score += 0.3
spam_reasons.append("suspicious_patterns")
if len(analysis.risk_indicators) >= 3:
spam_score += 0.2
spam_reasons.append("high_risk_indicators")
if analysis.sentiment_score < 0.4:
spam_score += 0.1
spam_reasons.append("negative_sentiment")
is_spam = spam_score >= 0.5
result = SpamDetectorResponse(
analysis=analysis, is_spam=is_spam, confidence_score=spam_score, spam_reasons=spam_reasons
)
await ctx.send_message(result)
class SpamHandler(Executor):
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
@handler
async def handle_spam_detection(
self,
spam_result: SpamDetectorResponse,
ctx: WorkflowContext[ProcessingResult],
) -> None:
"""Handle spam messages by quarantining and logging."""
if not spam_result.is_spam:
raise RuntimeError("Message is not spam, cannot process with spam handler.")
await asyncio.sleep(2.2) # Simulate spam handling time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
action_taken="quarantined_and_logged",
processing_time=2.2,
status="spam_handled",
is_spam=spam_result.is_spam,
confidence_score=spam_result.confidence_score,
spam_reasons=spam_result.spam_reasons or [],
)
await ctx.send_message(result)
class MessageResponder(Executor):
"""Step 4b: An executor that responds to legitimate messages."""
@handler
async def handle_spam_detection(
self,
spam_result: SpamDetectorResponse,
ctx: WorkflowContext[ProcessingResult],
) -> None:
"""Respond to legitimate messages."""
if spam_result.is_spam:
raise RuntimeError("Message is spam, cannot respond with message responder.")
await asyncio.sleep(2.5) # Simulate response time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
action_taken="responded_and_filed",
processing_time=2.5,
status="message_processed",
is_spam=spam_result.is_spam,
confidence_score=spam_result.confidence_score,
spam_reasons=spam_result.spam_reasons or [],
)
await ctx.send_message(result)
class FinalProcessor(Executor):
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
@handler
async def handle_processing_result(
self,
result: ProcessingResult,
ctx: WorkflowContext[Never, str],
) -> None:
"""Complete the workflow with final processing and logging."""
await asyncio.sleep(1.5) # Simulate final processing time
total_time = result.processing_time + 1.5
# Include classification details in completion message
classification = "SPAM" if result.is_spam else "LEGITIMATE"
reasons = ", ".join(result.spam_reasons) if result.spam_reasons else "none"
completion_message = (
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}). "
f"Reasons: {reasons}. "
f"Action: {result.action_taken}, "
f"Status: {result.status}, "
f"Total time: {total_time:.1f}s"
)
await ctx.yield_output(completion_message)
# Create the workflow instance that DevUI can discover
spam_keywords = ["spam", "advertisement", "offer", "click here", "winner", "congratulations", "urgent"]
# Create all the executors for the 5-step workflow
email_preprocessor = EmailPreprocessor(id="email_preprocessor")
content_analyzer = ContentAnalyzer(id="content_analyzer")
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
spam_handler = SpamHandler(id="spam_handler")
message_responder = MessageResponder(id="message_responder")
final_processor = FinalProcessor(id="final_processor")
# Build the comprehensive 5-step workflow with branching logic
workflow = (
WorkflowBuilder()
.set_start_executor(email_preprocessor)
.add_edge(email_preprocessor, content_analyzer)
.add_edge(content_analyzer, spam_detector)
.add_switch_case_edge_group(
spam_detector,
[
Case(condition=lambda x: x.is_spam, target=spam_handler),
Default(target=message_responder),
],
)
.add_edge(spam_handler, final_processor)
.add_edge(message_responder, final_processor)
.build()
)
# Note: Workflow metadata is determined by executors and graph structure
def main():
"""Launch the spam detection workflow in DevUI."""
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Spam Detection Workflow")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: workflow_spam_detection")
# Launch server with the workflow
serve(entities=[workflow], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -1,69 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample weather agent for Agent Framework Debug UI."""
import os
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[0]
temp = 53
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
# Agent instance following Agent Framework conventions
agent = ChatAgent(
name="WeatherAgent",
description="A helpful agent that provides weather information and forecasts",
instructions="""
You are a weather assistant. You can provide current weather information
and forecasts for any location. Always be helpful and provide detailed
weather information when asked.
""",
chat_client=OpenAIChatClient(model_id=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4o")),
tools=[get_weather, get_forecast],
)
def main():
"""Launch the weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_WeatherAgent")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent sample for DevUI testing."""
from .agent import agent
__all__ = ["agent"]
@@ -1,133 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample weather agent for Agent Framework Debug UI."""
import os
from collections.abc import Awaitable, Callable
from typing import Annotated
from agent_framework import (
ChatAgent,
ChatContext,
ChatMessage,
ChatResponse,
FunctionInvocationContext,
Role,
chat_middleware,
function_middleware,
)
from agent_framework.azure import AzureOpenAIChatClient
@chat_middleware
async def security_filter_middleware(
context: ChatContext,
next: Callable[[ChatContext], Awaitable[None]],
) -> None:
"""Chat middleware that blocks requests containing sensitive information."""
# Block requests with sensitive information
blocked_terms = ["password", "secret", "api_key", "token"]
for message in context.messages:
if message.text:
message_lower = message.text.lower()
for term in blocked_terms:
if term in message_lower:
# Override the response without calling the LLM
context.result = ChatResponse(
messages=[
ChatMessage(
role=Role.ASSISTANT,
text=(
"I cannot process requests containing sensitive information. "
"Please rephrase your question without including passwords, secrets, "
"or other sensitive data."
),
)
]
)
return
await next(context)
@function_middleware
async def atlantis_location_filter_middleware(
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
"""Function middleware that blocks weather requests for Atlantis."""
# Check if location parameter is "atlantis"
location = getattr(context.arguments, "location", None)
if location and location.lower() == "atlantis":
context.result = (
"Blocked! Hold up right there!! Tell the user that "
"'Atlantis is a special place, we must never ask about the weather there!!'"
)
context.terminate = True
return
await next(context)
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
temperature = 53
return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C."
def get_forecast(
location: Annotated[str, "The location to get the forecast for."],
days: Annotated[int, "Number of days for forecast"] = 3,
) -> str:
"""Get weather forecast for multiple days."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
forecast = []
for day in range(1, days + 1):
condition = conditions[0]
temp = 53
forecast.append(f"Day {day}: {condition}, {temp}°C")
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
# Agent instance following Agent Framework conventions
agent = ChatAgent(
name="AzureWeatherAgent",
description="A helpful agent that provides weather information and forecasts",
instructions="""
You are a weather assistant. You can provide current weather information
and forecasts for any location. Always be helpful and provide detailed
weather information when asked.
""",
chat_client=AzureOpenAIChatClient(
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
),
tools=[get_weather, get_forecast],
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
)
def main():
"""Launch the Azure weather agent in DevUI."""
import logging
from agent_framework.devui import serve
# Setup logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Azure Weather Agent")
logger.info("Available at: http://localhost:8090")
logger.info("Entity ID: agent_AzureWeatherAgent")
# Launch server with the agent
serve(entities=[agent], port=8090, auto_open=True)
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sequential Agents Workflow - Writer → Reviewer."""
from .workflow import workflow
__all__ = ["workflow"]
@@ -1,167 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Workflow - Content Review with Quality Routing.
This sample demonstrates:
- Using agents directly as executors
- Conditional routing based on structured outputs
- Quality-based workflow paths with convergence
Use case: Content creation with automated review.
Writer creates content, Reviewer evaluates quality:
- High quality (score >= 80): → Publisher → Summarizer
- Low quality (score < 80): → Editor → Publisher → Summarizer
Both paths converge at Summarizer for final report.
"""
import os
from typing import Any
from agent_framework import AgentExecutorResponse, WorkflowBuilder
from agent_framework.azure import AzureOpenAIChatClient
from pydantic import BaseModel
# Define structured output for review results
class ReviewResult(BaseModel):
"""Review evaluation with scores and feedback."""
score: int # Overall quality score (0-100)
feedback: str # Concise, actionable feedback
clarity: int # Clarity score (0-100)
completeness: int # Completeness score (0-100)
accuracy: int # Accuracy score (0-100)
structure: int # Structure score (0-100)
# Condition function: route to editor if score < 80
def needs_editing(message: Any) -> bool:
"""Check if content needs editing based on review score."""
if not isinstance(message, AgentExecutorResponse):
return False
try:
review = ReviewResult.model_validate_json(message.agent_run_response.text)
return review.score < 80
except Exception:
return False
# Condition function: content is approved (score >= 80)
def is_approved(message: Any) -> bool:
"""Check if content is approved (high quality)."""
if not isinstance(message, AgentExecutorResponse):
return True
try:
review = ReviewResult.model_validate_json(message.agent_run_response.text)
return review.score >= 80
except Exception:
return True
# Create Azure OpenAI chat client
chat_client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""))
# Create Writer agent - generates content
writer = chat_client.create_agent(
name="Writer",
instructions=(
"You are an excellent content writer. "
"Create clear, engaging content based on the user's request. "
"Focus on clarity, accuracy, and proper structure."
),
)
# Create Reviewer agent - evaluates and provides structured feedback
reviewer = chat_client.create_agent(
name="Reviewer",
instructions=(
"You are an expert content reviewer. "
"Evaluate the writer's content based on:\n"
"1. Clarity - Is it easy to understand?\n"
"2. Completeness - Does it fully address the topic?\n"
"3. Accuracy - Is the information correct?\n"
"4. Structure - Is it well-organized?\n\n"
"Return a JSON object with:\n"
"- score: overall quality (0-100)\n"
"- feedback: concise, actionable feedback\n"
"- clarity, completeness, accuracy, structure: individual scores (0-100)"
),
response_format=ReviewResult,
)
# Create Editor agent - improves content based on feedback
editor = chat_client.create_agent(
name="Editor",
instructions=(
"You are a skilled editor. "
"You will receive content along with review feedback. "
"Improve the content by addressing all the issues mentioned in the feedback. "
"Maintain the original intent while enhancing clarity, completeness, accuracy, and structure."
),
)
# Create Publisher agent - formats content for publication
publisher = chat_client.create_agent(
name="Publisher",
instructions=(
"You are a publishing agent. "
"You receive either approved content or edited content. "
"Format it for publication with proper headings and structure."
),
)
# Create Summarizer agent - creates final publication report
summarizer = chat_client.create_agent(
name="Summarizer",
instructions=(
"You are a summarizer agent. "
"Create a final publication report that includes:\n"
"1. A brief summary of the published content\n"
"2. The workflow path taken (direct approval or edited)\n"
"3. Key highlights and takeaways\n"
"Keep it concise and professional."
),
)
# Build workflow with branching and convergence:
# Writer → Reviewer → [branches]:
# - If score >= 80: → Publisher → Summarizer (direct approval path)
# - If score < 80: → Editor → Publisher → Summarizer (improvement path)
# Both paths converge at Summarizer for final report
workflow = (
WorkflowBuilder()
.set_start_executor(writer)
.add_edge(writer, reviewer)
# Branch 1: High quality (>= 80) goes directly to publisher
.add_edge(reviewer, publisher, condition=is_approved)
# Branch 2: Low quality (< 80) goes to editor first, then publisher
.add_edge(reviewer, editor, condition=needs_editing)
.add_edge(editor, publisher)
# Both paths converge: Publisher → Summarizer
.add_edge(publisher, summarizer)
.build()
)
def main():
"""Launch the branching workflow in DevUI."""
import logging
from agent_framework.devui import serve
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Agent Workflow (Content Review with Quality Routing)")
logger.info("Available at: http://localhost:8093")
logger.info("\nThis workflow demonstrates:")
logger.info("- Conditional routing based on structured outputs")
logger.info("- Path 1 (score >= 80): Reviewer → Publisher → Summarizer")
logger.info("- Path 2 (score < 80): Reviewer → Editor → Publisher → Summarizer")
logger.info("- Both paths converge at Summarizer for final report")
serve(entities=[workflow], port=8093, auto_open=True)
if __name__ == "__main__":
main()
+14 -71
View File
@@ -10,6 +10,7 @@ import asyncio
import contextlib
import http.client
import json
import logging
import threading
import time
from pathlib import Path
@@ -20,12 +21,20 @@ from openai import OpenAI
from agent_framework_devui import DevServer
logger = logging.getLogger(__name__)
def start_server() -> tuple[str, Any]:
"""Start server with samples directory."""
# Get samples directory
# Get samples directory - updated path after samples were moved
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
# Samples are now in python/samples/getting_started/devui
samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
if not samples_dir.exists():
raise RuntimeError(f"Samples directory not found: {samples_dir}")
logger.info(f"Using samples directory: {samples_dir}")
# Create and start server with simplified parameters
server = DevServer(
@@ -41,7 +50,7 @@ def start_server() -> tuple[str, Any]:
app=app,
host="127.0.0.1",
port=8085,
log_level="info", # More verbose to see tracing setup
# log_level="info", # More verbose to see tracing setup
)
server_instance = uvicorn.Server(server_config)
@@ -80,10 +89,9 @@ def capture_agent_stream_with_tracing(client: OpenAI, agent_id: str, scenario: s
try:
stream = client.responses.create(
model="agent-framework",
model=agent_id, # DevUI uses model field as entity_id
input="Tell me about the weather in Tokyo. I want details.",
stream=True,
extra_body={"entity_id": agent_id},
)
events = []
@@ -122,13 +130,12 @@ def capture_workflow_stream_with_tracing(
try:
stream = client.responses.create(
model="agent-framework",
model=workflow_id, # DevUI uses model field as entity_id
input=(
"Process this spam detection workflow with multiple emails: "
"'Buy now!', 'Hello mom', 'URGENT: Click here!'"
),
stream=True,
extra_body={"entity_id": workflow_id},
)
events = []
@@ -161,70 +168,6 @@ def capture_workflow_stream_with_tracing(
return [error_event]
def capture_agent_with_bad_config(base_url: str, agent_id: str) -> list[dict[str, Any]]:
"""Capture agent events with intentionally bad configuration to test error handling."""
# Test with invalid API key
bad_client = OpenAI(base_url=f"{base_url}/v1", api_key="invalid-api-key-123")
try:
return capture_agent_stream_with_tracing(bad_client, agent_id, "bad_api_key")
except Exception as e:
return [
{
"type": "error",
"scenario": "bad_api_key",
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
}
]
def capture_agent_with_wrong_model(base_url: str, agent_id: str) -> list[dict[str, Any]]:
"""Capture agent events with wrong model name to test error handling."""
client = OpenAI(
base_url=f"{base_url}/v1",
api_key="dummy-key", # Use the same key as success case
)
try:
stream = client.responses.create(
model="gpt-4-nonexistent-model", # Wrong model name
input="Tell me about the weather in Tokyo. I want details.",
stream=True,
extra_body={"entity_id": agent_id},
)
events = []
for event in stream:
# Serialize the entire event object
try:
event_dict = json.loads(event.model_dump_json())
except Exception:
# Fallback to dict conversion if model_dump_json fails
event_dict = event.__dict__ if hasattr(event, "__dict__") else str(event)
events.append(event_dict)
if len(events) >= 200:
break
return events
except Exception as e:
return [
{
"type": "error",
"scenario": "wrong_model",
"error_message": str(e),
"error_type": type(e).__name__,
"timestamp": time.time(),
}
]
def main():
"""Main capture script - testing both success and failure scenarios."""
@@ -0,0 +1,335 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for conversation store implementation."""
from typing import cast
import pytest
from openai.types.conversations import InputFileContent, InputImageContent, InputTextContent
from agent_framework_devui._conversations import InMemoryConversationStore
@pytest.mark.asyncio
async def test_create_conversation():
"""Test creating a conversation."""
store = InMemoryConversationStore()
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
assert conversation.id.startswith("conv_")
assert conversation.object == "conversation"
assert conversation.metadata == {"agent_id": "test_agent"}
@pytest.mark.asyncio
async def test_get_conversation():
"""Test retrieving a conversation."""
store = InMemoryConversationStore()
# Create conversation
created = store.create_conversation(metadata={"agent_id": "test_agent"})
# Retrieve it
retrieved = store.get_conversation(created.id)
assert retrieved is not None
assert retrieved.id == created.id
assert retrieved.metadata == {"agent_id": "test_agent"}
@pytest.mark.asyncio
async def test_get_conversation_not_found():
"""Test retrieving non-existent conversation."""
store = InMemoryConversationStore()
conversation = store.get_conversation("conv_nonexistent")
assert conversation is None
@pytest.mark.asyncio
async def test_update_conversation():
"""Test updating conversation metadata."""
store = InMemoryConversationStore()
# Create conversation
created = store.create_conversation(metadata={"agent_id": "test_agent"})
# Update metadata
updated = store.update_conversation(created.id, metadata={"agent_id": "new_agent", "session_id": "sess_123"})
assert updated.id == created.id
assert updated.metadata == {"agent_id": "new_agent", "session_id": "sess_123"}
@pytest.mark.asyncio
async def test_delete_conversation():
"""Test deleting a conversation."""
store = InMemoryConversationStore()
# Create conversation
created = store.create_conversation(metadata={"agent_id": "test_agent"})
# Delete it
result = store.delete_conversation(created.id)
assert result.id == created.id
assert result.deleted is True
assert result.object == "conversation.deleted"
# Verify it's gone
assert store.get_conversation(created.id) is None
@pytest.mark.asyncio
async def test_get_thread():
"""Test getting underlying AgentThread."""
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get thread
thread = store.get_thread(conversation.id)
assert thread is not None
# AgentThread should have message_store
assert hasattr(thread, "message_store")
@pytest.mark.asyncio
async def test_get_thread_not_found():
"""Test getting thread for non-existent conversation."""
store = InMemoryConversationStore()
thread = store.get_thread("conv_nonexistent")
assert thread is None
@pytest.mark.asyncio
async def test_list_conversations_by_metadata():
"""Test filtering conversations by metadata."""
store = InMemoryConversationStore()
# Create multiple conversations
_conv1 = store.create_conversation(metadata={"agent_id": "agent1"})
_conv2 = store.create_conversation(metadata={"agent_id": "agent2"})
conv3 = store.create_conversation(metadata={"agent_id": "agent1", "session_id": "sess_1"})
# Filter by agent_id
results = store.list_conversations_by_metadata({"agent_id": "agent1"})
assert len(results) == 2
assert all(cast(dict[str, str], c.metadata).get("agent_id") == "agent1" for c in results if c.metadata)
# Filter by agent_id and session_id
results = store.list_conversations_by_metadata({"agent_id": "agent1", "session_id": "sess_1"})
assert len(results) == 1
assert results[0].id == conv3.id
@pytest.mark.asyncio
async def test_add_items():
"""Test adding items to conversation."""
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Add items
items = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]
conv_items = await store.add_items(conversation.id, items=items)
assert len(conv_items) == 1
# Message is a ConversationItem type - check standard OpenAI fields
assert conv_items[0].type == "message"
assert conv_items[0].role == "user"
assert conv_items[0].status == "completed"
assert len(conv_items[0].content) == 1
assert conv_items[0].content[0].type == "text"
text_content = cast(InputTextContent, conv_items[0].content[0])
assert text_content.text == "Hello"
@pytest.mark.asyncio
async def test_list_items():
"""Test listing conversation items."""
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Add items
items = [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "assistant", "content": [{"type": "text", "text": "Hi there"}]},
]
await store.add_items(conversation.id, items=items)
# List items
retrieved_items, has_more = await store.list_items(conversation.id)
assert len(retrieved_items) >= 2 # At least the items we added
assert has_more is False
@pytest.mark.asyncio
async def test_list_items_pagination():
"""Test pagination when listing items."""
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Add multiple items
items = [{"role": "user", "content": [{"type": "text", "text": f"Message {i}"}]} for i in range(5)]
await store.add_items(conversation.id, items=items)
# List with limit
retrieved_items, has_more = await store.list_items(conversation.id, limit=3)
assert len(retrieved_items) == 3
assert has_more is True
@pytest.mark.asyncio
async def test_list_items_converts_function_calls():
"""Test that list_items properly converts function calls to ResponseFunctionToolCallItem."""
from agent_framework import ChatMessage, ChatMessageStore, Role
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get the underlying thread and set up message store
thread = store.get_thread(conversation.id)
assert thread is not None
# Initialize message store if not present
if thread.message_store is None:
thread.message_store = ChatMessageStore()
# Simulate messages from agent execution with function calls
messages = [
ChatMessage(role=Role.USER, contents=[{"type": "text", "text": "What's the weather in SF?"}]),
ChatMessage(
role=Role.ASSISTANT,
contents=[
{
"type": "function_call",
"name": "get_weather",
"arguments": '{"city": "San Francisco"}',
"call_id": "call_test123",
}
],
),
ChatMessage(
role=Role.TOOL,
contents=[
{
"type": "function_result",
"call_id": "call_test123",
"output": '{"temperature": 65, "condition": "sunny"}',
}
],
),
ChatMessage(role=Role.ASSISTANT, contents=[{"type": "text", "text": "The weather is sunny, 65°F"}]),
]
# Add messages to thread
await thread.on_new_messages(messages)
# List conversation items
items, has_more = await store.list_items(conversation.id)
# Verify we got the right number and types of items
assert len(items) == 4, f"Expected 4 items, got {len(items)}"
assert has_more is False
# Check item types
assert items[0].type == "message", "First item should be a message"
assert items[0].role == "user"
assert len(items[0].content) == 1
text_content_0 = cast(InputTextContent, items[0].content[0])
assert text_content_0.text == "What's the weather in SF?"
assert items[1].type == "function_call", "Second item should be a function_call"
assert items[1].call_id == "call_test123"
assert items[1].name == "get_weather"
assert items[1].arguments == '{"city": "San Francisco"}'
assert items[1].status == "completed"
assert items[2].type == "function_call_output", "Third item should be a function_call_output"
assert items[2].call_id == "call_test123"
assert items[2].output == '{"temperature": 65, "condition": "sunny"}'
assert items[2].status == "completed"
assert items[3].type == "message", "Fourth item should be a message"
assert items[3].role == "assistant"
assert len(items[3].content) == 1
text_content_3 = cast(InputTextContent, items[3].content[0])
assert text_content_3.text == "The weather is sunny, 65°F"
# CRITICAL: Ensure no empty message items
for item in items:
if item.type == "message":
assert len(item.content) > 0, f"Message item {item.id} has empty content!"
@pytest.mark.asyncio
async def test_list_items_handles_images_and_files():
"""Test that list_items properly converts data content (images/files) to OpenAI types."""
from agent_framework import ChatMessage, ChatMessageStore, Role
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get the underlying thread
thread = store.get_thread(conversation.id)
assert thread is not None
if thread.message_store is None:
thread.message_store = ChatMessageStore()
# Simulate message with image and file
messages = [
ChatMessage(
role=Role.USER,
contents=[
{"type": "text", "text": "Check this image and PDF"},
{"type": "data", "uri": "data:image/png;base64,iVBORw0KGgo=", "media_type": "image/png"},
{"type": "data", "uri": "data:application/pdf;base64,JVBERi0=", "media_type": "application/pdf"},
],
),
]
await thread.on_new_messages(messages)
# List items
items, has_more = await store.list_items(conversation.id)
assert len(items) == 1
assert items[0].type == "message"
assert items[0].role == "user"
assert len(items[0].content) == 3
# Check content types
assert items[0].content[0].type == "text"
text_content = cast(InputTextContent, items[0].content[0])
assert text_content.text == "Check this image and PDF"
assert items[0].content[1].type == "input_image"
image_content = cast(InputImageContent, items[0].content[1])
assert image_content.image_url == "data:image/png;base64,iVBORw0KGgo="
assert image_content.detail == "auto"
assert items[0].content[2].type == "input_file"
file_content = cast(InputFileContent, items[0].content[2])
assert file_content.file_url == "data:application/pdf;base64,JVBERi0="
+55 -2
View File
@@ -14,9 +14,10 @@ from agent_framework_devui._discovery import EntityDiscovery
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
# Get the samples directory relative to the current test file
# Get the samples directory from the main python samples folder
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
# Navigate to python/samples/getting_started/devui
samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
return str(samples_dir.resolve())
@@ -66,6 +67,58 @@ async def test_empty_directory():
assert len(entities) == 0
async def test_discovery_accepts_agents_with_only_run():
"""Test that discovery accepts agents with only run() method."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agent with only run() method
agent_dir = temp_path / "non_streaming_agent"
agent_dir.mkdir()
init_file = agent_dir / "__init__.py"
init_file.write_text("""
from agent_framework import AgentRunResponse, AgentThread, ChatMessage, Role, TextContent
class NonStreamingAgent:
id = "non_streaming"
name = "Non-Streaming Agent"
description = "Agent without run_stream"
@property
def display_name(self):
return self.name
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
messages=[ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="response")]
)],
response_id="test"
)
def get_new_thread(self, **kwargs):
return AgentThread()
agent = NonStreamingAgent()
""")
discovery = EntityDiscovery(str(temp_path))
entities = await discovery.discover_entities()
# Should discover the non-streaming agent
agents = [e for e in entities if e.type == "agent"]
assert len(agents) == 1
# ID is auto-generated, just check it exists and starts with agent_
assert agents[0].id.startswith("agent_")
assert agents[0].name == "Non-Streaming Agent"
assert not agents[0].metadata.get("has_run_stream")
if __name__ == "__main__":
# Simple test runner
async def run_tests():
+72 -15
View File
@@ -12,7 +12,7 @@ import pytest
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor, EntityNotFoundError
from agent_framework_devui._mapper import MessageMapper
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
class _DummyStartExecutor:
@@ -38,8 +38,10 @@ class _DummyWorkflow:
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
# Get the samples directory from the main python samples folder
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
# Navigate to python/samples/getting_started/devui
samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
return str(samples_dir.resolve())
@@ -94,13 +96,17 @@ async def test_executor_sync_execution(executor):
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
# Use simplified routing: model = entity_id
request = AgentFrameworkRequest(
model="agent-framework", input="test data", stream=False, extra_body=AgentFrameworkExtraBody(entity_id=agent_id)
model=agent_id, # Model IS the entity_id
input="test data",
stream=False,
)
response = await executor.execute_sync(request)
assert response.model == "agent-framework"
# With simplified routing, response.model reflects the actual agent_id
assert response.model == agent_id
assert response.object == "response"
assert len(response.output) > 0
assert response.usage.total_tokens > 0
@@ -116,11 +122,11 @@ async def test_executor_streaming_execution(executor):
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
# Use simplified routing: model = entity_id
request = AgentFrameworkRequest(
model="agent-framework",
model=agent_id, # Model IS the entity_id
input="streaming test",
stream=True,
extra_body=AgentFrameworkExtraBody(entity_id=agent_id),
)
event_count = 0
@@ -145,16 +151,16 @@ async def test_executor_invalid_entity_id(executor):
async def test_executor_missing_entity_id(executor):
"""Test execution without entity ID."""
"""Test get_entity_id returns model field (simplified routing)."""
request = AgentFrameworkRequest(
model="agent-framework",
model="my_agent",
input="test",
stream=False,
extra_body=None, # Test case for missing entity_id
)
# With simplified routing, model field IS the entity_id
entity_id = request.get_entity_id()
assert entity_id is None
assert entity_id == "my_agent"
def test_executor_get_start_executor_message_types_uses_handlers():
@@ -171,10 +177,11 @@ def test_executor_get_start_executor_message_types_uses_handlers():
def test_executor_select_primary_input_prefers_string():
"""Select string input even when discovered after other handlers."""
executor = AgentFrameworkExecutor(EntityDiscovery(None), MessageMapper())
from agent_framework_devui._utils import select_primary_input_type
placeholder_type = type("Placeholder", (), {})
chosen = executor._select_primary_input_type([placeholder_type, str])
chosen = select_primary_input_type([placeholder_type, str])
assert chosen is str
@@ -201,6 +208,57 @@ def test_executor_parse_raw_falls_back_to_string():
assert parsed == "hi there"
async def test_executor_handles_non_streaming_agent():
"""Test executor can handle agents with only run() method (no run_stream)."""
from agent_framework import AgentRunResponse, AgentThread, ChatMessage, Role, TextContent
class NonStreamingAgent:
"""Agent with only run() method - does NOT satisfy full AgentProtocol."""
id = "non_streaming_test"
name = "Non-Streaming Test Agent"
description = "Test agent without run_stream()"
@property
def display_name(self):
return self.name
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=f"Processed: {messages}")])],
response_id="test_123",
)
def get_new_thread(self, **kwargs):
return AgentThread()
# Create executor and register agent
discovery = EntityDiscovery(None)
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
agent = NonStreamingAgent()
entity_info = await discovery.create_entity_info_from_object(agent, source="test")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute non-streaming agent (use simplified routing)
request = AgentFrameworkRequest(
model=entity_info.id, # Model IS the entity_id
input="hello",
stream=True, # DevUI always streams
)
events = []
async for event in executor.execute_streaming(request):
events.append(event)
# Should get events even though agent doesn't stream
assert len(events) > 0
text_events = [e for e in events if hasattr(e, "type") and e.type == "response.output_text.delta"]
assert len(text_events) > 0
assert "Processed: hello" in text_events[0].delta
if __name__ == "__main__":
# Simple test runner
async def run_tests():
@@ -227,12 +285,11 @@ class StreamingAgent:
entities = await executor.discover_entities()
if entities:
# Test sync execution
# Test sync execution (use simplified routing)
request = AgentFrameworkRequest(
model="agent-framework",
model=entities[0].id, # Model IS the entity_id
input="test input",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=entities[0].id),
)
await executor.execute_sync(request)
+34 -8
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "main"))
from agent_framework._types import AgentRunResponseUpdate, ErrorContent, FunctionCallContent, Role, TextContent
from agent_framework_devui._mapper import MessageMapper
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
def create_test_content(content_type: str, **kwargs: Any) -> Any:
@@ -48,11 +48,11 @@ def mapper() -> MessageMapper:
@pytest.fixture
def test_request() -> AgentFrameworkRequest:
# Use simplified routing: model = entity_id
return AgentFrameworkRequest(
model="agent-framework",
model="test_agent", # Model IS the entity_id
input="Test input",
stream=True,
extra_body=AgentFrameworkExtraBody(entity_id="test_agent"),
)
@@ -97,11 +97,14 @@ async def test_function_call_mapping(mapper: MessageMapper, test_request: AgentF
events = await mapper.convert_event(update, test_request)
assert len(events) >= 1
assert all(event.type == "response.function_call_arguments.delta" for event in events)
# Should generate: response.output_item.added + response.function_call_arguments.delta
assert len(events) >= 2
assert events[0].type == "response.output_item.added"
assert events[1].type == "response.function_call_arguments.delta"
# Check JSON is chunked
full_json = "".join(event.delta for event in events)
# Check JSON is in delta event
delta_events = [e for e in events if e.type == "response.function_call_arguments.delta"]
full_json = "".join(event.delta for event in delta_events)
assert "TestCity" in full_json
@@ -158,12 +161,35 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
assert "WeirdUnknownContent" in event.delta
async def test_agent_run_response_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test that mapper handles complete AgentRunResponse (non-streaming)."""
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
# Create a complete response like agent.run() would return
message = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="Complete response from run()")],
)
response = AgentRunResponse(messages=[message], response_id="test_resp_123")
# Mapper should convert it to streaming events
events = await mapper.convert_event(response, test_request)
assert len(events) > 0
# Should produce text delta events
text_events = [e for e in events if e.type == "response.output_text.delta"]
assert len(text_events) > 0
assert text_events[0].delta == "Complete response from run()"
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
mapper = MessageMapper()
test_request = AgentFrameworkRequest(
model="agent-framework", input="Test", stream=True, extra_body=AgentFrameworkExtraBody(entity_id="test")
model="test",
input="Test",
stream=True,
)
tests = [
+18 -14
View File
@@ -9,8 +9,8 @@ from pathlib import Path
import pytest
from agent_framework_devui import DevServer
from agent_framework_devui._server import _extract_executor_message_types, _select_primary_input_type
from agent_framework_devui.models._openai_custom import AgentFrameworkExtraBody, AgentFrameworkRequest
from agent_framework_devui._utils import extract_executor_message_types, select_primary_input_type
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
class _StubExecutor:
@@ -26,8 +26,10 @@ class _StubExecutor:
@pytest.fixture
def test_entities_dir():
"""Use the samples directory which has proper entity structure."""
# Get the samples directory from the main python samples folder
current_dir = Path(__file__).parent
samples_dir = current_dir.parent / "samples"
# Navigate to python/samples/getting_started/devui
samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
return str(samples_dir.resolve())
@@ -65,15 +67,15 @@ async def test_server_execution_sync(test_entities_dir):
entities = await executor.discover_entities()
agent_id = entities[0].id
# Use model as entity_id (new simplified routing)
request = AgentFrameworkRequest(
model="agent-framework",
model=agent_id, # model IS the entity_id now!
input="San Francisco",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=agent_id),
)
response = await executor.execute_sync(request)
assert response.model == "agent-framework"
assert response.model == agent_id # Should echo back the model (entity_id)
assert len(response.output) > 0
@@ -85,8 +87,11 @@ async def test_server_execution_streaming(test_entities_dir):
entities = await executor.discover_entities()
agent_id = entities[0].id
# Use model as entity_id (new simplified routing)
request = AgentFrameworkRequest(
model="agent-framework", input="New York", stream=True, extra_body=AgentFrameworkExtraBody(entity_id=agent_id)
model=agent_id, # model IS the entity_id now!
input="New York",
stream=True,
)
event_count = 0
@@ -112,7 +117,7 @@ def test_extract_executor_message_types_prefers_input_types():
"""Input types property is used when available."""
stub = _StubExecutor(input_types=[str, dict])
types = _extract_executor_message_types(stub)
types = extract_executor_message_types(stub)
assert types == [str, dict]
@@ -121,7 +126,7 @@ def test_extract_executor_message_types_falls_back_to_handlers():
"""Handlers provide message metadata when input_types missing."""
stub = _StubExecutor(handlers={str: object(), int: object()})
types = _extract_executor_message_types(stub)
types = extract_executor_message_types(stub)
assert str in types
assert int in types
@@ -129,9 +134,9 @@ def test_extract_executor_message_types_falls_back_to_handlers():
def test_select_primary_input_type_prefers_string_and_dict():
"""Primary type selection prefers user-friendly primitives."""
string_first = _select_primary_input_type([dict[str, str], str])
dict_first = _select_primary_input_type([dict[str, str]])
fallback = _select_primary_input_type([int, float])
string_first = select_primary_input_type([dict[str, str], str])
dict_first = select_primary_input_type([dict[str, str]])
fallback = select_primary_input_type([int, float])
assert string_first is str
assert dict_first is dict
@@ -162,10 +167,9 @@ class WeatherAgent:
if entities:
request = AgentFrameworkRequest(
model="agent-framework",
model=entities[0].id, # model IS the entity_id now!
input="test location",
stream=False,
extra_body=AgentFrameworkExtraBody(entity_id=entities[0].id),
)
await executor.execute_sync(request)