mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI - Internal Refactor, Conversations API support, and per… (#1235)
* Python: DevUI - Internal Refactor, Conversations API support, and performance improvements Comprehensive refactor of DevUI package including samples relocation, frontend reorganization, OpenAI Conversations API support, and critical performance and code quality improvements. Key Changes: Architecture & Organization - Moved DevUI samples to python/samples/getting_started/devui/ - Consolidated with other framework samples for better discoverability - Added .env.example files and comprehensive README - Restructured frontend components into feature-based folders (agent, workflow, gallery, layout) - Created new OpenAI-compliant message renderers (devui should render oai responses types primarily) New Features - Added _conversations.py (467 lines) - Full conversation storage abstraction, replaces the /threads endpoint to better match oai conversations api - Implements OpenAI Conversations API for thread management, Supports in-memory and extensible storage backends API Simplification - Use 'model' field as entity_id (agent/workflow name) instead of extra_body - Use standard OpenAI 'conversation' field for conversation context. Performance & Quality Improvements - Improved context management in MessageMapper with bounded memory (~500KB max) - Implemented hybrid LRU + cleanup approach to prevent unbounded memory growth - General QOL improvement - Eliminated ~150 lines of dead/duplicate code, Consolidated helper functions into _utils.py, Extracted magic numbers to module-level constants, Optimized conversation item lookups with index-based approach Testing - Added test_conversations.py (13 tests) - Added test_performance_fixes.py (9 tests) - Updated existing tests for code consolidation - 53 tests passing Impact: 76 files changed: +4,106 insertions, -2,373 deletions All linting and formatting checks passing. No breaking changes - backward compatible. Migration: Samples moved to python/samples/getting_started/devui/ * readme lint fixes * initial support for function approval and minor ui fixes
This commit is contained in:
committed by
GitHub
Unverified
parent
f5abbc67ae
commit
c341ee7ed2
+106
-13
@@ -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>
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
+532
-350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Agent Feature - Exports
|
||||
*/
|
||||
|
||||
export { AgentView } from "./agent-view";
|
||||
export { AgentDetailsModal } from "./agent-details-modal";
|
||||
export * from "./message-renderers";
|
||||
+289
@@ -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>
|
||||
);
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
+7
@@ -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";
|
||||
+5
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
+380
-71
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
+213
-111
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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="
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user