Python: DevUI: Add OpenAI Responses API proxy support + HIL for Workflows (#1737)

* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

This commit adds support for proxying requests to OpenAI's Responses API,
allowing DevUI to route conversations to OpenAI models when configured to enable testing.

Backend changes:
- Add OpenAI proxy executor with conversation routing logic
- Enhance event mapper to support OpenAI Responses API format
- Extend server endpoints to handle OpenAI proxy mode
- Update models with OpenAI-specific response types
- Remove emojis from logging and CLI output for cleaner text

Frontend changes:
- Add settings modal with OpenAI proxy configuration UI
- Enhance agent and workflow views with improved state management
- Add new UI components (separator, switch) for settings
- Update debug panel with better event filtering
- Improve message renderers for OpenAI content types
- Update types and API client for OpenAI integration

* update ui, settings modal and workflow input form, add register cleanup hooks.

* add workflow HIL support, user mode, other fixes

* feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas

Implement  HIL workflow support allowing workflows to pause for user input
with dynamically generated JSON schemas based on response handler type hints.

Key Features:
- Automatic response schema extraction from @response_handler decorators
- Dynamic form generation in UI based on Pydantic/dataclass response types
- Checkpoint-based conversation storage for HIL requests/responses
- Resume workflow execution after user provides HIL response

Backend Changes:
- Add extract_response_type_from_executor() to introspect response handlers
- Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema()
- Map RequestInfoEvent to response.input.requested OpenAI event format
- Store HIL responses in conversation history and restore checkpoints

Frontend Changes:
- Add HILInputModal component with SchemaFormRenderer for dynamic forms
- Support Pydantic BaseModel and dataclass response types
- Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects
- Display original request context alongside response form

Testing:
- Add  tests for checkpoint storage (test_checkpoints.py)
- Add schema generation tests for all input types (test_schema_generation.py)
- Validate end-to-end HIL flow with spam workflow sample

This enables workflows to seamlessly pause execution and request structured user input
with type-safe, validated forms generated automatically from response type annotations.

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-11-07 23:28:32 +00:00
committed by GitHub
co-authored by Mark Wallace
parent 85484c0259
commit 94eae24082
52 changed files with 10177 additions and 1598 deletions
+78 -7
View File
@@ -62,6 +62,27 @@ serve(entities=[agent])
MCP tools use lazy initialization and connect automatically on first use. DevUI attempts to clean up connections on shutdown
## Resource Cleanup
Register cleanup hooks to properly close credentials and resources on shutdown:
```python
from azure.identity.aio import DefaultAzureCredential
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_devui import register_cleanup, serve
credential = DefaultAzureCredential()
client = AzureOpenAIChatClient()
agent = ChatAgent(name="MyAgent", chat_client=client)
# Register cleanup hook - credential will be closed on shutdown
register_cleanup(agent, credential.close)
serve(entities=[agent])
```
Works with multiple resources and file-based discovery. See tests for more examples.
## Directory Structure
For your agents to be discovered by the DevUI, they must be organized in a directory structure like below. Each agent/workflow must have an `__init__.py` that exports the required variable (`agent` or `workflow`).
@@ -150,6 +171,22 @@ response2 = client.responses.create(
**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.
### OpenAI Proxy Mode
DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly through the interface without creating custom agents. Enable via Settings → OpenAI Proxy tab.
**How it works:** The UI sends requests to the DevUI backend (with `X-Proxy-Backend: openai` header), which then proxies them to OpenAI's Responses API (and Conversations API for multi-turn chats). This proxy approach keeps your `OPENAI_API_KEY` secure on the server—never exposed in the browser or client-side code.
**Example:**
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "X-Proxy-Backend: openai" \
-d '{"model": "gpt-4.1-mini", "input": "Hello"}'
```
**Note:** Requires `OPENAI_API_KEY` environment variable configured on the backend.
## CLI Options
```bash
@@ -162,6 +199,21 @@ Options:
--config YAML config file
--tracing none|framework|workflow|all
--reload Enable auto-reload
--mode developer|user (default: developer)
--auth Enable Bearer token authentication
```
### UI Modes
- **developer** (default): Full access - debug panel, entity details, hot reload, deployment
- **user**: Simplified UI with restricted APIs - only chat and conversation management
```bash
# Development
devui ./agents
# Production (user-facing)
devui ./agents --mode user --auth
```
## Key Endpoints
@@ -187,18 +239,23 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
| `response.function_result.complete` | `FunctionResultContent` | DevUI |
| `response.function_approval.requested` | `FunctionApprovalRequestContent` | DevUI |
| `response.function_approval.responded` | `FunctionApprovalResponseContent` | DevUI |
| `response.output_item.added` (ResponseOutputImage) | `DataContent` (images) | DevUI |
| `response.output_item.added` (ResponseOutputFile) | `DataContent` (files) | DevUI |
| `response.output_item.added` (ResponseOutputData) | `DataContent` (other) | DevUI |
| `response.output_item.added` (ResponseOutputImage/File) | `UriContent` (images/files) | DevUI |
| `error` | `ErrorContent` | OpenAI |
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
| | **Workflow Events** | |
| `response.output_item.added` (ExecutorActionItem)* | `ExecutorInvokedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem)* | `ExecutorCompletedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem with error)* | `ExecutorFailedEvent` | OpenAI |
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowOutputEvent` | OpenAI |
| `response.workflow_event.complete` | `WorkflowEvent` (other) | DevUI |
| `response.trace.complete` | `WorkflowStatusEvent` | DevUI |
| `response.trace.complete` | `WorkflowWarningEvent` | DevUI |
| | **Trace Content** | |
| `response.trace.complete` | `DataContent` | DevUI |
| `response.trace.complete` | `UriContent` | DevUI |
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
| `response.trace.complete` | `HostedFileContent` | DevUI |
| `response.trace.complete` | `HostedVectorStoreContent` | DevUI |
@@ -213,15 +270,19 @@ DevUI follows the OpenAI Responses API specification for maximum compatibility:
**OpenAI Standard Event Types Used:**
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls and results)
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls, images, files, data)
- `ResponseOutputItemDoneEvent` - Output item completion notifications
- `Response.usage` - Token usage (in final response, not streamed)
- All standard text, reasoning, and function call events
**Custom DevUI Extensions:**
- `response.output_item.added` with custom item types:
- `ResponseOutputImage` - Agent-generated images (inline display)
- `ResponseOutputFile` - Agent-generated files (inline display)
- `ResponseOutputData` - Agent-generated structured data (inline display)
- `response.function_approval.requested` - Function approval requests (for interactive approval workflows)
- `response.function_approval.responded` - Function approval responses (user approval/rejection)
- `response.function_result.complete` - Server-side function execution results
- `response.workflow_event.complete` - Agent Framework workflow events
- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores)
@@ -254,18 +315,28 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
## Security
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks or used in production environments.
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication.
**For production deployments:**
```bash
# User mode with authentication (recommended)
devui ./agents --mode user --auth --host 0.0.0.0
```
This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication.
**Security features:**
- User mode restricts developer-facing APIs
- Optional Bearer token authentication via `--auth`
- Only loads entities from local directories or in-memory registration
- No remote code execution capabilities
- Binds to localhost (127.0.0.1) by default
- All samples must be manually downloaded and reviewed before running
**Best practices:**
- Never expose DevUI to the internet
- Use `--mode user --auth` for any deployment exposed to end users
- Review all agent/workflow code before running
- Only load entities from trusted sources
- Use `.env` files for sensitive credentials (never commit them)
@@ -5,20 +5,87 @@
import importlib.metadata
import logging
import webbrowser
from collections.abc import Callable
from typing import Any
from ._conversations import CheckpointConversationManager
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
logger = logging.getLogger(__name__)
# Module-level cleanup registry (before serve() is called)
_cleanup_registry: dict[int, list[Callable[[], Any]]] = {}
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
"""Register cleanup hook(s) for an entity.
Cleanup hooks execute during DevUI server shutdown, before entity
clients are closed. Supports both synchronous and asynchronous callables.
Args:
entity: Agent, workflow, or other entity object
*hooks: One or more cleanup callables (sync or async)
Raises:
ValueError: If no hooks provided
Examples:
Single cleanup hook:
>>> from agent_framework.devui import serve, register_cleanup
>>> credential = DefaultAzureCredential()
>>> agent = ChatAgent(...)
>>> register_cleanup(agent, credential.close)
>>> serve(entities=[agent])
Multiple cleanup hooks:
>>> register_cleanup(agent, credential.close, session.close, db_pool.close)
Works with file-based discovery:
>>> # In agents/my_agent/agent.py
>>> from agent_framework.devui import register_cleanup
>>> credential = DefaultAzureCredential()
>>> agent = ChatAgent(...)
>>> register_cleanup(agent, credential.close)
>>> # Run: devui ./agents
"""
if not hooks:
raise ValueError("At least one cleanup hook required")
# Use id() to track entity identity (works across modules)
entity_id = id(entity)
if entity_id not in _cleanup_registry:
_cleanup_registry[entity_id] = []
_cleanup_registry[entity_id].extend(hooks)
logger.debug(
f"Registered {len(hooks)} cleanup hook(s) for {type(entity).__name__} "
f"(id: {entity_id}, total: {len(_cleanup_registry[entity_id])})"
)
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]:
"""Get cleanup hooks registered for an entity (internal use).
Args:
entity: Entity object to get hooks for
Returns:
List of cleanup hooks registered for the entity
"""
entity_id = id(entity)
return _cleanup_registry.get(entity_id, [])
def serve(
entities: list[Any] | None = None,
entities_dir: str | None = None,
@@ -28,6 +95,9 @@ def serve(
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
tracing_enabled: bool = False,
mode: str = "developer",
auth_enabled: bool = False,
auth_token: str | None = None,
) -> None:
"""Launch Agent Framework DevUI with simple API.
@@ -40,6 +110,9 @@ def serve(
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
tracing_enabled: Whether to enable OpenTelemetry tracing
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
auth_enabled: Whether to enable Bearer token authentication
auth_token: Custom authentication token (auto-generated if not provided with auth_enabled=True)
"""
import re
@@ -53,6 +126,52 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Security check: Warn if network-exposed without authentication
if host not in ("127.0.0.1", "localhost") and not auth_enabled:
logger.warning("⚠️ WARNING: Exposing DevUI to network without authentication!")
logger.warning("⚠️ This is INSECURE - anyone on your network can access your agents")
logger.warning("💡 For network exposure, add --auth flag: devui --host 0.0.0.0 --auth")
# Handle authentication configuration
if auth_enabled:
import os
import secrets
# Check if token is in environment variable first
if not auth_token:
auth_token = os.environ.get("DEVUI_AUTH_TOKEN")
# Auto-generate token if STILL not provided
if not auth_token:
# Check if we're in a production-like environment
is_production = (
host not in ("127.0.0.1", "localhost") # Exposed to network
or os.environ.get("CI") == "true" # Running in CI
or os.environ.get("KUBERNETES_SERVICE_HOST") # Running in k8s
)
if is_production:
# REFUSE to start without explicit token
logger.error("❌ Authentication enabled but no token provided")
logger.error("❌ Auto-generated tokens are NOT secure for network-exposed deployments")
logger.error("💡 Set token: export DEVUI_AUTH_TOKEN=<your-secure-token>")
logger.error("💡 Or pass: serve(entities=[...], auth_token='your-token')")
raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost")
# Development mode: auto-generate and show
auth_token = secrets.token_urlsafe(32)
logger.info("🔒 Authentication enabled with auto-generated token")
logger.info("\n" + "=" * 70)
logger.info("🔑 DEV TOKEN (localhost only, shown once):")
logger.info(f" {auth_token}")
logger.info("=" * 70 + "\n")
else:
logger.info("🔒 Authentication enabled with provided token")
# Set environment variable for server to use
os.environ["AUTH_REQUIRED"] = "true"
os.environ["DEVUI_AUTH_TOKEN"] = auth_token
# Configure tracing environment variables if enabled
if tracing_enabled:
import os
@@ -72,7 +191,12 @@ def serve(
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
entities_dir=entities_dir,
port=port,
host=host,
cors_origins=cors_origins,
ui_enabled=ui_enabled,
mode=mode,
)
# Register in-memory entities if provided
@@ -139,6 +263,7 @@ def main() -> None:
# Export main public API
__all__ = [
"AgentFrameworkRequest",
"CheckpointConversationManager",
"DevServer",
"DiscoveryResponse",
"EntityInfo",
@@ -147,5 +272,6 @@ __all__ = [
"OpenAIResponse",
"ResponseStreamEvent",
"main",
"register_cleanup",
"serve",
]
@@ -55,6 +55,41 @@ Examples:
parser.add_argument("--tracing", action="store_true", help="Enable OpenTelemetry tracing for Agent Framework")
parser.add_argument(
"--mode",
choices=["developer", "user"],
default=None,
help="Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)",
)
# Add --dev/--no-dev as a convenient alternative to --mode
parser.add_argument(
"--dev",
dest="dev_mode",
action="store_true",
default=None,
help="Enable developer mode (shorthand for --mode developer)",
)
parser.add_argument(
"--no-dev",
dest="dev_mode",
action="store_false",
help="Disable developer mode (shorthand for --mode user)",
)
parser.add_argument(
"--auth",
action="store_true",
help="Enable authentication via Bearer token (required for deployed environments)",
)
parser.add_argument(
"--auth-token",
type=str,
help="Custom authentication token (auto-generated if not provided with --auth)",
)
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
return parser
@@ -78,26 +113,35 @@ def validate_directory(directory: str) -> str:
abs_dir = os.path.abspath(directory)
if not os.path.exists(abs_dir):
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
sys.exit(1)
if not os.path.isdir(abs_dir):
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
sys.exit(1)
return abs_dir
def print_startup_info(entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool) -> None:
def print_startup_info(
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
) -> None:
"""Print startup information."""
print("🤖 Agent Framework DevUI") # noqa: T201
print("Agent Framework DevUI") # noqa: T201
print("=" * 50) # noqa: T201
print(f"📁 Entities directory: {entities_dir}") # noqa: T201
print(f"🌐 Server URL: http://{host}:{port}") # noqa: T201
print(f"🎨 UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
print(f"🔄 Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
print(f"Entities directory: {entities_dir}") # noqa: T201
print(f"Server URL: http://{host}:{port}") # noqa: T201
print(f"UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
print(f"Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
# Display auth token if authentication is enabled
if auth_token:
print("Authentication: Enabled") # noqa: T201
print(f"Auth token: {auth_token}") # noqa: T201
print("💡 Use this token in Authorization: Bearer <token> header") # noqa: T201
print("=" * 50) # noqa: T201
print("🔍 Scanning for entities...") # noqa: T201
print("Scanning for entities...") # noqa: T201
def main() -> None:
@@ -114,8 +158,19 @@ def main() -> None:
# Extract parameters directly from args
ui_enabled = not args.headless
# Print startup info
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload)
# Determine mode from --mode or --dev/--no-dev flags
if args.dev_mode is not None:
# --dev or --no-dev was specified
mode = "developer" if args.dev_mode else "user"
elif args.mode is not None:
# --mode was specified
mode = args.mode
else:
# Default to developer mode
mode = "developer"
# Print startup info (don't show token - serve() will handle it)
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload, None)
# Import and start server
try:
@@ -128,14 +183,17 @@ def main() -> None:
auto_open=not args.no_open,
ui_enabled=ui_enabled,
tracing_enabled=args.tracing,
mode=mode,
auth_enabled=args.auth,
auth_token=args.auth_token, # Pass through explicit token only
)
except KeyboardInterrupt:
print("\n👋 Shutting down Agent Framework DevUI...") # noqa: T201
print("\nShutting down Agent Framework DevUI...") # noqa: T201
sys.exit(0)
except Exception as e:
logger.exception("Failed to start server")
print(f"Error: {e}", file=sys.stderr) # noqa: T201
print(f"Error: {e}", file=sys.stderr) # noqa: T201
sys.exit(1)
@@ -12,6 +12,7 @@ from abc import ABC, abstractmethod
from typing import Any, Literal, cast
from agent_framework import AgentThread, ChatMessage
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.conversations.message import Message
@@ -26,6 +27,10 @@ from openai.types.responses import (
# Type alias for OpenAI Message role literals
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
# Checkpoint item type constants
CONVERSATION_ITEM_TYPE_CHECKPOINT = "checkpoint"
CONVERSATION_TYPE_CHECKPOINT_CONTAINER = "checkpoint_container"
class ConversationStore(ABC):
"""Abstract base class for conversation storage.
@@ -35,14 +40,17 @@ class ConversationStore(ABC):
"""
@abstractmethod
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation (wraps AgentThread creation).
Args:
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
conversation_id: Optional conversation ID (if None, generates one)
Returns:
Conversation object with generated ID
Conversation object with generated or provided ID
"""
pass
@@ -127,7 +135,7 @@ class ConversationStore(ABC):
@abstractmethod
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get specific conversation item.
"""Get a specific conversation item by ID.
Args:
conversation_id: Conversation ID
@@ -184,17 +192,23 @@ class InMemoryConversationStore(ConversationStore):
# 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}"
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation with underlying AgentThread and checkpoint storage."""
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
created_at = int(time.time())
# Create AgentThread with default ChatMessageStore
thread = AgentThread()
# Create session-scoped checkpoint storage (one per conversation)
checkpoint_storage = InMemoryCheckpointStorage()
self._conversations[conv_id] = {
"id": conv_id,
"thread": thread,
"checkpoint_storage": checkpoint_storage, # Stored alongside thread
"metadata": metadata or {},
"created_at": created_at,
"items": [],
@@ -424,6 +438,23 @@ class InMemoryConversationStore(ConversationStore):
# Add function result items
items.extend(function_results)
# Include checkpoints from checkpoint storage as conversation items
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
# Get all checkpoints for this conversation
checkpoints = await checkpoint_storage.list_checkpoints()
for checkpoint in checkpoints:
# Create a conversation item for each checkpoint
checkpoint_item = {
"id": f"checkpoint_{checkpoint.checkpoint_id}",
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
"workflow_id": checkpoint.workflow_id,
"timestamp": checkpoint.timestamp,
"status": "completed",
}
items.append(cast(ConversationItem, checkpoint_item))
# Apply pagination
if order == "desc":
items = items[::-1]
@@ -442,12 +473,9 @@ class InMemoryConversationStore(ConversationStore):
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
"""Get a specific conversation item by ID."""
# Use the item index for O(1) lookup
conv_items = self._item_index.get(conversation_id, {})
return conv_items.get(item_id)
def get_thread(self, conversation_id: str) -> AgentThread | None:
@@ -471,3 +499,42 @@ class InMemoryConversationStore(ConversationStore):
)
)
return results
class CheckpointConversationManager:
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
Simplified architecture: Each conversation has its own InMemoryCheckpointStorage
stored in conv_data["checkpoint_storage"]. This manager just retrieves it.
Session isolation comes from each conversation having a separate storage instance.
"""
def __init__(self, conversation_store: ConversationStore):
# Runtime validation since we need specific implementation details
if not isinstance(conversation_store, InMemoryConversationStore):
raise TypeError("CheckpointConversationManager currently requires InMemoryConversationStore")
self._store: InMemoryConversationStore = conversation_store
# Keep public reference for backward compatibility with tests
self.conversation_store = conversation_store
def get_checkpoint_storage(self, conversation_id: str) -> InMemoryCheckpointStorage:
"""Get the checkpoint storage for a specific conversation.
Args:
conversation_id: Conversation ID
Returns:
InMemoryCheckpointStorage instance for this conversation
Raises:
ValueError: If conversation not found
"""
# Access internal conversations dict (we know it's InMemoryConversationStore)
conv_data = self._store._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
checkpoint_storage = conv_data["checkpoint_storage"]
if not isinstance(checkpoint_storage, InMemoryCheckpointStorage):
raise TypeError(f"Expected InMemoryCheckpointStorage but got {type(checkpoint_storage)}")
return checkpoint_storage
@@ -0,0 +1,588 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Container Apps deployment manager for DevUI entities."""
import asyncio
import logging
import re
import secrets
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from pathlib import Path
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
logger = logging.getLogger(__name__)
class DeploymentManager:
"""Manages entity deployments to Azure Container Apps."""
def __init__(self) -> None:
"""Initialize deployment manager."""
self._deployments: dict[str, Deployment] = {}
async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGenerator[DeploymentEvent, None]:
"""Deploy entity to Azure Container Apps with streaming events.
Args:
config: Deployment configuration
entity_path: Path to entity directory
Yields:
DeploymentEvent objects for real-time progress updates
Raises:
ValueError: If prerequisites not met or deployment fails
"""
deployment_id = str(uuid.uuid4())
try:
# Step 1: Validate prerequisites
yield DeploymentEvent(
type="deploy.validating",
message="Checking prerequisites (Azure CLI, Docker, authentication)...",
)
await self._validate_prerequisites()
# Step 2: Generate Dockerfile
yield DeploymentEvent(
type="deploy.dockerfile",
message="Generating Dockerfile with authentication enabled...",
)
_ = await self._generate_dockerfile(entity_path, config)
# Step 3: Generate auth token
yield DeploymentEvent(
type="deploy.token",
message="Generating secure authentication token...",
)
auth_token = secrets.token_urlsafe(32)
# Step 4: Discover existing Container App Environment
yield DeploymentEvent(
type="deploy.environment",
message="Checking for existing Container App Environment...",
)
# Step 5: Build and deploy with Azure CLI
yield DeploymentEvent(
type="deploy.building",
message=f"Deploying to Azure Container Apps ({config.region})...",
)
# Create a queue for streaming events from subprocess
event_queue: asyncio.Queue[DeploymentEvent] = asyncio.Queue()
# Run deployment in background task with event queue
deployment_task = asyncio.create_task(self._deploy_to_azure(config, entity_path, auth_token, event_queue))
# Stream events from queue while deployment runs
while True:
try:
# Check if deployment task is done
if deployment_task.done():
# Get the result or exception
deployment_url = await deployment_task
break
# Get event from queue with short timeout
event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
yield event
except asyncio.TimeoutError:
# No event in queue, continue waiting
continue
# Step 5: Store deployment record
deployment = Deployment(
id=deployment_id,
entity_id=config.entity_id,
resource_group=config.resource_group,
app_name=config.app_name,
region=config.region,
url=deployment_url,
status="deployed",
created_at=datetime.now(timezone.utc).isoformat(),
)
self._deployments[deployment_id] = deployment
# Step 6: Success - return URL and token
yield DeploymentEvent(
type="deploy.completed",
message=f"Deployment successful! URL: {deployment_url}",
url=deployment_url,
auth_token=auth_token, # Shown once to user
)
except Exception as e:
error_msg = f"Deployment failed: {e!s}"
logger.exception(error_msg)
# Store failed deployment
deployment = Deployment(
id=deployment_id,
entity_id=config.entity_id,
resource_group=config.resource_group,
app_name=config.app_name,
region=config.region,
url="",
status="failed",
created_at=datetime.now(timezone.utc).isoformat(),
error=str(e),
)
self._deployments[deployment_id] = deployment
yield DeploymentEvent(
type="deploy.failed",
message=error_msg,
)
async def _validate_prerequisites(self) -> None:
"""Validate that Azure CLI, Docker, authentication, and resource providers are available.
Raises:
ValueError: If prerequisites not met
"""
# Check Azure CLI
az_check = await asyncio.create_subprocess_exec(
"az", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await az_check.communicate()
if az_check.returncode != 0:
raise ValueError(
"Azure CLI not found. Install from: https://learn.microsoft.com/cli/azure/install-azure-cli"
)
# Check Docker
docker_check = await asyncio.create_subprocess_exec(
"docker", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await docker_check.communicate()
if docker_check.returncode != 0:
raise ValueError("Docker not found. Install from: https://www.docker.com/get-started")
# Check Azure authentication
az_account_check = await asyncio.create_subprocess_exec(
"az", "account", "show", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await az_account_check.communicate()
if az_account_check.returncode != 0:
raise ValueError("Not authenticated with Azure. Run: az login")
# Check required resource providers are registered
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
unregistered_providers = []
# Get list of registered providers
provider_check = await asyncio.create_subprocess_exec(
"az",
"provider",
"list",
"--query",
"[?registrationState=='Registered'].namespace",
"--output",
"json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await provider_check.communicate()
if provider_check.returncode == 0:
import json
try:
registered = json.loads(stdout.decode())
for provider in required_providers:
if provider not in registered:
unregistered_providers.append(provider)
except json.JSONDecodeError:
logger.warning("Could not parse provider list, skipping provider validation")
else:
logger.warning("Could not check provider registration status")
if unregistered_providers:
commands = [f"az provider register -n {p} --wait" for p in unregistered_providers]
raise ValueError(
f"Required Azure resource providers not registered: {', '.join(unregistered_providers)}\n\n"
f"Register them by running:\n" + "\n".join(commands) + "\n\n"
"This is a one-time setup per Azure subscription."
)
logger.info("All prerequisites validated successfully")
async def _generate_dockerfile(self, entity_path: Path, config: DeploymentConfig) -> Path:
"""Generate Dockerfile for entity deployment.
Args:
entity_path: Path to entity directory
config: Deployment configuration
Returns:
Path to generated Dockerfile
"""
# Validate ui_mode
if config.ui_mode not in ["user", "developer"]:
raise ValueError(f"Invalid ui_mode: {config.ui_mode}. Must be 'user' or 'developer'.")
# Check if requirements.txt exists in the entity directory
has_requirements = (entity_path / "requirements.txt").exists()
requirements_section = ""
if has_requirements:
logger.info(f"Found requirements.txt in {entity_path}, will include in Dockerfile")
requirements_section = """# Install entity dependencies
COPY requirements.txt ./
RUN pip install -r requirements.txt
"""
else:
logger.info(f"No requirements.txt found in {entity_path}, skipping dependency installation")
dockerfile_content = f"""FROM python:3.11-slim
WORKDIR /app
{requirements_section}# Install DevUI from PyPI
RUN pip install agent-framework-devui --pre
# Copy entity code
COPY . /app/entity/
ENV PORT=8080
EXPOSE 8080
# Launch DevUI with auth enabled (token from environment variable)
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080", "--auth"]
"""
dockerfile_path = entity_path / "Dockerfile"
# Warn if Dockerfile already exists
if dockerfile_path.exists():
logger.warning(f"Dockerfile already exists at {dockerfile_path}, overwriting...")
dockerfile_path.write_text(dockerfile_content)
logger.info(f"Generated Dockerfile at {dockerfile_path}")
return dockerfile_path
async def _discover_container_app_environment(self, resource_group: str, region: str) -> str | None:
"""Discover existing Container App Environment in resource group.
Args:
resource_group: Resource group name
region: Azure region (for filtering if needed)
Returns:
Environment name if found, None otherwise
"""
cmd = [
"az",
"containerapp",
"env",
"list",
"--resource-group",
resource_group,
"--query",
"[0].name",
"--output",
"tsv",
]
logger.info(f"Discovering existing Container App Environments in {resource_group}...")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
env_name = stdout.decode().strip()
if env_name:
logger.info(f"Found existing environment: {env_name}")
return env_name
logger.info("No existing environments found in resource group")
return None
logger.warning(f"Failed to query environments: {stderr.decode()}")
return None
async def _deploy_to_azure(
self, config: DeploymentConfig, entity_path: Path, auth_token: str, event_queue: asyncio.Queue[DeploymentEvent]
) -> str:
"""Deploy to Azure Container Apps, reusing existing environments.
Args:
config: Deployment configuration
entity_path: Path to entity directory
auth_token: Authentication token to inject
event_queue: Queue for streaming progress events
Returns:
Deployment URL
Raises:
ValueError: If deployment fails
"""
# Step 1: Try to discover existing Container App Environment
existing_env = await self._discover_container_app_environment(config.resource_group, config.region)
if existing_env:
# Use existing environment - avoids needing environment creation permissions
logger.info(f"Reusing existing Container App Environment: {existing_env} (cost efficient, no side effects)")
cmd = [
"az",
"containerapp",
"up",
"--name",
config.app_name,
"--resource-group",
config.resource_group,
"--environment",
existing_env,
"--source",
str(entity_path),
"--env-vars",
f"DEVUI_AUTH_TOKEN={auth_token}",
"--ingress",
"external",
"--target-port",
"8080",
]
logger.info(f"Creating new Container App '{config.app_name}' in environment '{existing_env}'...")
else:
# No existing environment - try to create one (may fail if no permissions)
logger.warning(
"No existing Container App Environment found. "
"Attempting to create new environment (requires Microsoft.App/managedEnvironments/write permission)..."
)
cmd = [
"az",
"containerapp",
"up",
"--name",
config.app_name,
"--resource-group",
config.resource_group,
"--location",
config.region,
"--source",
str(entity_path),
"--env-vars",
f"DEVUI_AUTH_TOKEN={auth_token}",
"--ingress",
"external",
"--target-port",
"8080",
]
logger.info(f"Running: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
# Stream output line by line
output_lines = []
try:
if not process.stdout:
raise ValueError("Failed to capture process output")
while True:
# Read with timeout
line = await asyncio.wait_for(process.stdout.readline(), timeout=600)
if not line:
break
line_text = line.decode().strip()
if line_text:
output_lines.append(line_text)
# Stream meaningful updates to user
if "WARNING:" in line_text:
# Parse and send user-friendly warnings
if "Creating resource group" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message=f"Creating resource group '{config.resource_group}'...",
)
)
elif "Creating ContainerAppEnvironment" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message="Setting up Container App Environment (this may take 2-3 minutes)...",
)
)
elif "Registering resource provider" in line_text:
provider = line_text.split("provider")[-1].strip()
if provider.endswith("..."):
provider = provider[:-3]
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message=f"Registering Azure provider{provider}..."
)
)
elif "Creating Azure Container Registry" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Creating Container Registry for your images..."
)
)
elif "No Log Analytics workspace" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Creating Log Analytics workspace for monitoring..."
)
)
elif "Building image" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message="Building Docker image (this may take several minutes)...",
)
)
elif "Pushing image" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Pushing image to Azure Container Registry..."
)
)
elif "Creating Container App" in line_text:
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Creating your Container App...")
)
elif "Container app created" in line_text:
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Container app created successfully!")
)
elif "ERROR:" in line_text:
# Stream errors immediately
await event_queue.put(DeploymentEvent(type="deploy.error", message=line_text))
elif "Step" in line_text and "/" in line_text:
# Docker build steps
await event_queue.put(
DeploymentEvent(type="deploy.progress", message=f"Docker build: {line_text}")
)
elif "https://" in line_text and ".azurecontainerapps.io" in line_text:
# Deployment URL detected
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
)
# Wait for process to complete
return_code = await process.wait()
if return_code != 0:
error_output = "\n".join(output_lines[-10:]) # Last 10 lines for context
raise ValueError(f"Azure deployment failed:\n{error_output}")
except asyncio.TimeoutError as e:
process.kill()
raise ValueError(
"Azure deployment timed out after 10 minutes. Please check Azure portal for status."
) from e
# Parse output to extract FQDN
output = "\n".join(output_lines)
logger.debug(f"Azure CLI output: {output}")
# Extract FQDN from output (az containerapp up returns it)
# Format: https://<app-name>.<random-id>.<region>.azurecontainerapps.io
deployment_url = self._extract_fqdn_from_output(output, config.app_name)
logger.info(f"Deployment successful: {deployment_url}")
return deployment_url
def _extract_fqdn_from_output(self, output: str, app_name: str) -> str:
"""Extract FQDN from Azure CLI output.
Args:
output: Azure CLI command output
app_name: Container app name
Returns:
Full HTTPS URL to deployed app
"""
# Try to find FQDN in output
for line in output.split("\n"):
if "fqdn" in line.lower() or app_name in line:
# Extract URL-like string
match = re.search(r"https?://[\w\-\.]+\.azurecontainerapps\.io", line)
if match:
return match.group(0)
# If we can't extract FQDN, fail explicitly rather than return a broken URL
logger.error(f"Could not extract FQDN from Azure CLI output. Output:\n{output}")
raise ValueError(
"Could not extract deployment URL from Azure CLI output. "
"The deployment may have succeeded - check the Azure portal for your container app URL."
)
async def list_deployments(self, entity_id: str | None = None) -> list[Deployment]:
"""List all deployments, optionally filtered by entity.
Args:
entity_id: Optional entity ID to filter by
Returns:
List of deployment records
"""
if entity_id:
return [d for d in self._deployments.values() if d.entity_id == entity_id]
return list(self._deployments.values())
async def get_deployment(self, deployment_id: str) -> Deployment | None:
"""Get deployment by ID.
Args:
deployment_id: Deployment ID
Returns:
Deployment record or None if not found
"""
return self._deployments.get(deployment_id)
async def delete_deployment(self, deployment_id: str) -> None:
"""Delete deployment from Azure Container Apps.
Args:
deployment_id: Deployment ID to delete
Raises:
ValueError: If deployment not found or deletion fails
"""
deployment = self._deployments.get(deployment_id)
if not deployment:
raise ValueError(f"Deployment {deployment_id} not found")
# Execute: az containerapp delete
cmd = [
"az",
"containerapp",
"delete",
"--name",
deployment.app_name,
"--resource-group",
deployment.resource_group,
"--yes", # Skip confirmation
]
logger.info(f"Deleting deployment: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_output = stderr.decode() if stderr else stdout.decode()
raise ValueError(f"Deployment deletion failed: {error_output}")
# Remove from store
del self._deployments[deployment_id]
logger.info(f"Deployment {deployment_id} deleted successfully")
@@ -4,6 +4,7 @@
from __future__ import annotations
import ast
import importlib
import importlib.util
import logging
@@ -31,6 +32,7 @@ class EntityDiscovery:
self.entities_dir = entities_dir
self._entities: dict[str, EntityInfo] = {}
self._loaded_objects: dict[str, Any] = {}
self._cleanup_hooks: dict[str, list[Any]] = {}
async def discover_entities(self) -> list[EntityInfo]:
"""Scan for Agent Framework entities.
@@ -70,14 +72,15 @@ class EntityDiscovery:
"""
return self._loaded_objects.get(entity_id)
async def load_entity(self, entity_id: str) -> Any:
"""Load entity on-demand (lazy loading).
async def load_entity(self, entity_id: str, checkpoint_manager: Any = None) -> Any:
"""Load entity on-demand and inject checkpoint storage for workflows.
This method implements lazy loading by importing the entity module only when needed.
In-memory entities are returned from cache immediately.
Args:
entity_id: Entity identifier
checkpoint_manager: Optional checkpoint manager for workflow storage injection
Returns:
Loaded entity object
@@ -107,9 +110,13 @@ class EntityDiscovery:
else:
raise ValueError(
f"Unsupported entity source: {entity_info.source}. "
f"Only 'directory' and 'in_memory' sources are supported."
f"Only 'directory' and 'in-memory' sources are supported."
)
# Note: Checkpoint storage is now injected at runtime via run_stream() parameter,
# not at load time. This provides cleaner architecture and explicit control flow.
# See _executor.py _execute_workflow() for runtime checkpoint storage injection.
# Enrich metadata with actual entity data
# Don't pass entity_type if it's "unknown" - let inference determine the real type
enriched_info = await self.create_entity_info_from_object(
@@ -122,11 +129,27 @@ class EntityDiscovery:
# Preserve the original path from sparse metadata
if "path" in entity_info.metadata:
enriched_info.metadata["path"] = entity_info.metadata["path"]
# Now that we have the path, properly check deployment support
entity_path = Path(entity_info.metadata["path"])
deployment_supported, deployment_reason = self._check_deployment_support(entity_path, entity_info.source)
enriched_info.deployment_supported = deployment_supported
enriched_info.deployment_reason = deployment_reason
enriched_info.metadata["lazy_loaded"] = True
self._entities[entity_id] = enriched_info
# Cache the loaded object
self._loaded_objects[entity_id] = entity_obj
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
if registered_hooks:
if entity_id not in self._cleanup_hooks:
self._cleanup_hooks[entity_id] = []
self._cleanup_hooks[entity_id].extend(registered_hooks)
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
logger.info(f"Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
return entity_obj
@@ -187,6 +210,17 @@ class EntityDiscovery:
"""
return list(self._entities.values())
def get_cleanup_hooks(self, entity_id: str) -> list[Any]:
"""Get cleanup hooks registered for an entity.
Args:
entity_id: Entity identifier
Returns:
List of cleanup hooks for the entity
"""
return self._cleanup_hooks.get(entity_id, [])
def invalidate_entity(self, entity_id: str) -> None:
"""Invalidate (clear cache for) an entity to enable hot reload.
@@ -239,6 +273,17 @@ class EntityDiscovery:
"""
self._entities[entity_id] = entity_info
self._loaded_objects[entity_id] = entity_object
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
registered_hooks = _get_registered_cleanup_hooks(entity_object)
if registered_hooks:
if entity_id not in self._cleanup_hooks:
self._cleanup_hooks[entity_id] = []
self._cleanup_hooks[entity_id].extend(registered_hooks)
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
async def create_entity_info_from_object(
@@ -305,6 +350,17 @@ class EntityDiscovery:
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.")
# Check deployment support based on source
# For directory-based entities, we need the path to verify deployment support
deployment_supported = False
deployment_reason = "In-memory entities cannot be deployed (no source directory)"
if source == "directory":
# Directory-based entity - will be checked properly after enrichment when path is available
# For now, mark as potentially deployable - will be re-evaluated after enrichment
deployment_supported = True
deployment_reason = "Ready for deployment (pending path verification)"
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
@@ -321,6 +377,8 @@ class EntityDiscovery:
executors=tools_list if entity_type == "workflow" else [],
input_schema={"type": "string"}, # Default schema
start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"source": "agent_framework_object",
"class_name": entity_object.__class__.__name__
@@ -404,6 +462,31 @@ class EntityDiscovery:
# Has __init__.py but no specific file
return "unknown"
def _check_deployment_support(self, entity_path: Path, source: str) -> tuple[bool, str | None]:
"""Check if entity can be deployed to Azure Container Apps.
Args:
entity_path: Path to entity directory or file
source: Entity source ("directory" or "in_memory")
Returns:
Tuple of (supported, reason) explaining deployment eligibility
"""
# In-memory entities cannot be deployed
if source == "in_memory":
return False, "In-memory entities cannot be deployed (no source directory)"
# File-based entities need a directory structure for deployment
if not entity_path.is_dir():
return False, "Only directory-based entities can be deployed"
# Must have __init__.py
if not (entity_path / "__init__.py").exists():
return False, "Missing __init__.py file"
# Passed all checks
return True, "Ready for deployment"
def _register_sparse_entity(self, dir_path: Path) -> None:
"""Register entity with sparse metadata (no import).
@@ -413,6 +496,9 @@ class EntityDiscovery:
entity_id = dir_path.name
entity_type = self._detect_entity_type(dir_path)
# Check deployment support
deployment_supported, deployment_reason = self._check_deployment_support(dir_path, "directory")
entity_info = EntityInfo(
id=entity_id,
name=entity_id.replace("_", " ").title(),
@@ -421,6 +507,8 @@ class EntityDiscovery:
tools=[], # Sparse - will be populated on load
description="", # Sparse - will be populated on load
source="directory",
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"path": str(dir_path),
"discovered": True,
@@ -431,14 +519,52 @@ class EntityDiscovery:
self._entities[entity_id] = entity_info
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
def _has_entity_exports(self, file_path: Path) -> bool:
"""Check if a Python file has entity exports (agent or workflow) using AST parsing.
This safely checks for module-level assignments like:
- agent = ChatAgent(...)
- workflow = WorkflowBuilder()...
Args:
file_path: Python file to check
Returns:
True if file has 'agent' or 'workflow' exports
"""
try:
# Read and parse the file's AST
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(file_path))
# Look for module-level assignments of 'agent' or 'workflow'
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in ("agent", "workflow"):
return True
except Exception as e:
logger.debug(f"Could not parse {file_path} for entity exports: {e}")
return False
return False
def _register_sparse_file_entity(self, file_path: Path) -> None:
"""Register file-based entity with sparse metadata (no import).
Args:
file_path: Entity Python file
"""
# Check if file has valid entity exports using AST parsing
if not self._has_entity_exports(file_path):
logger.debug(f"Skipping {file_path.name} - no 'agent' or 'workflow' exports found")
return
entity_id = file_path.stem
# Check deployment support (file-based entities cannot be deployed)
deployment_supported, deployment_reason = self._check_deployment_support(file_path, "directory")
# File-based entities are typically agents, but we can't know for sure without importing
entity_info = EntityInfo(
id=entity_id,
@@ -448,6 +574,8 @@ class EntityDiscovery:
tools=[],
description="",
source="directory",
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"path": str(file_path),
"discovered": True,
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
from typing import Any
from agent_framework import AgentProtocol
from agent_framework._workflows._events import RequestInfoEvent
from ._conversations import ConversationStore, InMemoryConversationStore
from ._discovery import EntityDiscovery
@@ -50,6 +51,11 @@ class AgentFrameworkExecutor:
# Use provided conversation store or default to in-memory
self.conversation_store = conversation_store or InMemoryConversationStore()
# Create checkpoint manager (wraps conversation store)
from ._conversations import CheckpointConversationManager
self.checkpoint_manager = CheckpointConversationManager(self.conversation_store)
def _setup_tracing_provider(self) -> None:
"""Set up our own TracerProvider so we can add processors."""
try:
@@ -79,10 +85,20 @@ class AgentFrameworkExecutor:
# Configure Agent Framework tracing only if ENABLE_OTEL is set
if os.environ.get("ENABLE_OTEL"):
try:
from agent_framework.observability import setup_observability
from agent_framework.observability import OBSERVABILITY_SETTINGS, setup_observability
setup_observability(enable_sensitive_data=True)
logger.info("Enabled Agent Framework observability")
# Only configure if not already executed
if not OBSERVABILITY_SETTINGS._executed_setup:
# Get OTLP endpoint from either custom or standard env var
# This handles the case where env vars are set after ObservabilitySettings was imported
otlp_endpoint = os.environ.get("OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
# Pass the endpoint explicitly to setup_observability
# This ensures OTLP exporters are created even if env vars were set late
setup_observability(enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
logger.info("Enabled Agent Framework observability")
else:
logger.debug("Agent Framework observability already configured")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework observability: {e}")
else:
@@ -173,7 +189,7 @@ class AgentFrameworkExecutor:
entity_info = self.get_entity_info(entity_id)
# Trigger lazy loading (will return from cache if already loaded)
entity_obj = await self.entity_discovery.load_entity(entity_id)
entity_obj = await self.entity_discovery.load_entity(entity_id, checkpoint_manager=self.checkpoint_manager)
if not entity_obj:
raise EntityNotFoundError(f"Entity object for '{entity_id}' not found")
@@ -190,6 +206,15 @@ class AgentFrameworkExecutor:
yield event
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
# Log RequestInfoEvent for debugging HIL flow
event_class = event.__class__.__name__ if hasattr(event, "__class__") else type(event).__name__
if event_class == "RequestInfoEvent":
logger.info("🔔 [EXECUTOR] RequestInfoEvent detected from workflow!")
logger.info(f" request_id: {getattr(event, 'request_id', 'N/A')}")
logger.info(f" source_executor_id: {getattr(event, 'source_executor_id', 'N/A')}")
logger.info(f" request_type: {getattr(event, 'request_type', 'N/A')}")
data = getattr(event, "data", None)
logger.info(f" data type: {type(data).__name__ if data else 'None'}")
yield event
else:
raise ValueError(f"Unsupported entity type: {entity_info.type}")
@@ -289,7 +314,7 @@ class AgentFrameworkExecutor:
async def _execute_workflow(
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
) -> AsyncGenerator[Any, None]:
"""Execute Agent Framework workflow with trace collection.
"""Execute Agent Framework workflow with checkpoint support via conversation items.
Args:
workflow: Workflow object to execute
@@ -300,23 +325,199 @@ class AgentFrameworkExecutor:
Workflow events and trace events
"""
try:
# Get input data directly from request.input field
input_data = request.input
logger.debug(f"Using input field: {type(input_data)}")
entity_id = request.get_entity_id() or "unknown"
# Parse input based on workflow's expected input type
parsed_input = await self._parse_workflow_input(workflow, input_data)
# Get or create session conversation for checkpoint storage
conversation_id = request.get_conversation_id()
if not conversation_id:
# Create default session if not provided
import time
import uuid
logger.debug(f"Executing workflow with parsed input type: {type(parsed_input)}")
conversation_id = f"session_{entity_id}_{uuid.uuid4().hex[:8]}"
logger.info(f"Created new workflow session: {conversation_id}")
# Use Agent Framework workflow's native streaming
async for event in workflow.run_stream(parsed_input):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Create conversation in store
self.conversation_store.create_conversation(
metadata={
"entity_id": entity_id,
"type": "workflow_session",
"created_at": str(int(time.time())),
},
conversation_id=conversation_id,
)
else:
# Validate conversation exists, create if missing (handles deleted conversations)
import time
# Then yield the workflow event
yield event
existing = self.conversation_store.get_conversation(conversation_id)
if not existing:
logger.warning(f"Conversation {conversation_id} not found (may have been deleted), recreating")
self.conversation_store.create_conversation(
metadata={
"entity_id": entity_id,
"type": "workflow_session",
"created_at": str(int(time.time())),
},
conversation_id=conversation_id,
)
# Get session-scoped checkpoint storage (InMemoryCheckpointStorage from conv_data)
# Each conversation has its own storage instance, providing automatic session isolation.
# This storage is passed to workflow.run_stream() which sets it as runtime override,
# ensuring all checkpoint operations (save/load) use THIS conversation's storage.
# The framework guarantees runtime storage takes precedence over build-time storage.
checkpoint_storage = self.checkpoint_manager.get_checkpoint_storage(conversation_id)
# Check for HIL responses first
hil_responses = self._extract_workflow_hil_responses(request.input)
# Determine checkpoint_id (explicit or auto-latest for HIL responses)
checkpoint_id = None
if request.extra_body and "checkpoint_id" in request.extra_body:
checkpoint_id = request.extra_body["checkpoint_id"]
logger.debug(f"Using explicit checkpoint_id from request: {checkpoint_id}")
elif hil_responses:
# Only auto-resume from latest checkpoint when we have HIL responses
# Regular "Run" clicks should start fresh, not resume from checkpoints
checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed!
if checkpoints:
latest = max(checkpoints, key=lambda cp: cp.timestamp)
checkpoint_id = latest.checkpoint_id
logger.info(f"Auto-resuming from latest checkpoint in session {conversation_id}: {checkpoint_id}")
else:
logger.warning(f"HIL responses received but no checkpoints in session {conversation_id}")
if hil_responses:
# HIL continuation mode requires checkpointing
if not checkpoint_id:
error_msg = (
"Cannot process HIL responses without a checkpoint. "
"Workflows using HIL must be configured with .with_checkpointing() "
"and a checkpoint must exist before sending responses."
)
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
return
logger.info(f"Resuming workflow with HIL responses for {len(hil_responses)} request(s)")
# Unwrap primitive responses if they're wrapped in {response: value} format
from ._utils import parse_input_for_type
unwrapped_responses = {}
for request_id, response_value in hil_responses.items():
if isinstance(response_value, dict) and "response" in response_value:
response_value = response_value["response"]
unwrapped_responses[request_id] = response_value
hil_responses = unwrapped_responses
# NOTE: Two-step approach for stateless HTTP (framework limitation):
# 1. Restore checkpoint to load pending requests into workflow's in-memory state
# 2. Then send responses using send_responses_streaming
# Future: Framework should support run_stream(checkpoint_id, responses) in single call
# (checkpoint_id is guaranteed to exist due to earlier validation)
logger.debug(f"Restoring checkpoint {checkpoint_id} then sending HIL responses")
try:
# Step 1: Restore checkpoint to populate workflow's in-memory pending requests
restored = False
async for _event in workflow.run_stream(
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
):
restored = True
break # Stop immediately after restoration, don't process events
if not restored:
raise RuntimeError("Checkpoint restoration did not yield any events")
# Reset running flags so we can call send_responses_streaming
if hasattr(workflow, "_is_running"):
workflow._is_running = False
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "_running"):
workflow._runner._running = False
# Extract response types from restored workflow and convert responses to proper types
try:
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "context"):
runner_context = workflow._runner.context
pending_requests_dict = await runner_context.get_pending_request_info_events()
converted_responses = {}
for request_id, response_value in hil_responses.items():
if request_id in pending_requests_dict:
pending_request = pending_requests_dict[request_id]
if hasattr(pending_request, "response_type"):
response_type = pending_request.response_type
try:
response_value = parse_input_for_type(response_value, response_type)
logger.debug(
f"Converted HIL response for {request_id} to {type(response_value)}"
)
except Exception as e:
logger.warning(f"Failed to convert HIL response for {request_id}: {e}")
converted_responses[request_id] = response_value
hil_responses = converted_responses
except Exception as e:
logger.warning(f"Could not convert HIL responses to proper types: {e}")
# Step 2: Now send responses to the in-memory workflow
async for event in workflow.send_responses_streaming(hil_responses):
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
except (AttributeError, ValueError, RuntimeError) as e:
error_msg = f"Failed to send HIL responses: {e}"
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
elif checkpoint_id:
# Resume from checkpoint (explicit or auto-latest) using unified API
logger.info(f"Resuming workflow from checkpoint {checkpoint_id} in session {conversation_id}")
try:
async for event in workflow.run_stream(
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
):
if isinstance(event, RequestInfoEvent):
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
except ValueError as e:
error_msg = f"Cannot resume from checkpoint: {e}"
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
else:
# First run - pass DevUI's checkpoint storage to enable checkpointing
logger.info(f"Starting fresh workflow in session {conversation_id}")
parsed_input = await self._parse_workflow_input(workflow, request.input)
async for event in workflow.run_stream(parsed_input, checkpoint_storage=checkpoint_storage):
if isinstance(event, RequestInfoEvent):
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
except Exception as e:
logger.error(f"Error in workflow execution: {e}")
@@ -569,6 +770,59 @@ class AgentFrameworkExecutor:
return start_executor, message_types
def _extract_workflow_hil_responses(self, input_data: Any) -> dict[str, Any] | None:
"""Extract workflow HIL responses from OpenAI input format.
Looks for special content type: workflow_hil_response
Args:
input_data: OpenAI ResponseInputParam
Returns:
Dict of {request_id: response_value} if found, None otherwise
"""
if not isinstance(input_data, list):
return None
for item in input_data:
if isinstance(item, dict) and item.get("type") == "message":
message_content = item.get("content", [])
if isinstance(message_content, list):
for content_item in message_content:
if isinstance(content_item, dict):
content_type = content_item.get("type")
if content_type == "workflow_hil_response":
# Extract responses dict
# dict.get() returns Any, so we explicitly type it
responses: dict[str, Any] = content_item.get("responses", {}) # type: ignore[assignment]
logger.info(f"Found workflow HIL responses: {list(responses.keys())}")
return responses
return None
def _get_or_create_conversation(self, conversation_id: str, entity_id: str) -> Any:
"""Get existing conversation or create a new one.
Args:
conversation_id: Conversation ID from frontend
entity_id: Entity ID (e.g., "spam_workflow") for metadata filtering
Returns:
Conversation object
"""
conversation = self.conversation_store.get_conversation(conversation_id)
if not conversation:
# Create conversation with frontend's ID
# Use agent_id in metadata so it can be filtered by list_conversations(agent_id=...)
conversation = self.conversation_store.create_conversation(
metadata={"agent_id": entity_id}, conversation_id=conversation_id
)
logger.info(f"Created conversation {conversation_id} for entity {entity_id}")
return conversation
def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any:
"""Parse structured input data for workflow execution.
@@ -644,3 +898,53 @@ class AgentFrameworkExecutor:
except Exception as e:
logger.debug(f"Error parsing workflow input: {e}")
return raw_input
def _enrich_request_info_event_with_response_schema(self, event: Any, workflow: Any) -> None:
"""Extract response type from workflow executor and attach response schema to RequestInfoEvent.
Args:
event: RequestInfoEvent to enrich
workflow: Workflow object containing executors
"""
try:
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
# Get source executor ID and request type from event
source_executor_id = getattr(event, "source_executor_id", None)
request_type = getattr(event, "request_type", None)
if not source_executor_id or not request_type:
logger.debug("RequestInfoEvent missing source_executor_id or request_type")
return
# Find the source executor in the workflow
if not hasattr(workflow, "executors") or not isinstance(workflow.executors, dict):
logger.debug("Workflow doesn't have executors dict")
return
source_executor = workflow.executors.get(source_executor_id)
if not source_executor:
logger.debug(f"Could not find executor '{source_executor_id}' in workflow")
return
# Extract response type from the executor's handler signature
response_type = extract_response_type_from_executor(source_executor, request_type)
if response_type:
# Generate JSON schema for response type
response_schema = generate_input_schema(response_type)
# Attach response_schema to event for mapper to include in output
event._response_schema = response_schema
logger.debug(f"Extracted response schema for {request_type.__name__}: {response_schema}")
else:
# Even if extraction fails, provide a reasonable default to avoid warnings
logger.debug(
f"Could not extract response type for {request_type.__name__}, using default string schema"
)
response_schema = {"type": "string"}
event._response_schema = response_schema
except Exception as e:
logger.warning(f"Failed to enrich RequestInfoEvent with response schema: {e}")
@@ -34,6 +34,9 @@ from .models import (
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionResultComplete,
ResponseFunctionToolCall,
ResponseOutputData,
ResponseOutputFile,
ResponseOutputImage,
ResponseOutputItemAddedEvent,
ResponseOutputMessage,
ResponseOutputText,
@@ -160,7 +163,7 @@ class MessageMapper:
if isinstance(raw_event, ResponseTraceEvent):
return [
ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data=raw_event.data,
item_id=context["item_id"],
sequence_number=self._next_sequence(context),
@@ -338,6 +341,147 @@ class MessageMapper:
context["sequence_counter"] += 1
return int(context["sequence_counter"])
def _serialize_value(self, value: Any) -> Any:
"""Recursively serialize a value, handling complex nested objects.
Handles:
- Primitives (str, int, float, bool, None)
- Collections (list, tuple, set, dict)
- SerializationMixin objects (ChatMessage, etc.) - calls to_dict()
- Pydantic models - calls model_dump()
- Dataclasses - recursively serializes with asdict()
- Enums - extracts value
- datetime/date/UUID - converts to ISO string
Args:
value: Value to serialize
Returns:
JSON-serializable representation
"""
from dataclasses import is_dataclass
from datetime import date, datetime
from enum import Enum
from uuid import UUID
# Handle None
if value is None:
return None
# Handle primitives
if isinstance(value, (str, int, float, bool)):
return value
# Handle datetime/date - convert to ISO format
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
# Handle UUID - convert to string
if isinstance(value, UUID):
return str(value)
# Handle Enums - extract value
if isinstance(value, Enum):
return value.value
# Handle lists/tuples/sets - recursively serialize elements
if isinstance(value, (list, tuple)):
return [self._serialize_value(item) for item in value]
if isinstance(value, set):
return [self._serialize_value(item) for item in value]
# Handle dicts - recursively serialize values
if isinstance(value, dict):
return {k: self._serialize_value(v) for k, v in value.items()}
# Handle SerializationMixin (like ChatMessage) - call to_dict()
if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)):
try:
return value.to_dict() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize with to_dict(): {e}")
return str(value)
# Handle Pydantic models - call model_dump()
if hasattr(value, "model_dump") and callable(getattr(value, "model_dump", None)):
try:
return value.model_dump() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize Pydantic model: {e}")
return str(value)
# Handle dataclasses - recursively serialize with asdict
if is_dataclass(value) and not isinstance(value, type):
try:
from dataclasses import asdict
# Use our custom serializer as dict_factory
return asdict(value, dict_factory=lambda items: {k: self._serialize_value(v) for k, v in items})
except Exception as e:
logger.debug(f"Failed to serialize nested dataclass: {e}")
return str(value)
# Fallback: convert to string (for unknown types)
logger.debug(f"Serializing unknown type {type(value).__name__} as string")
return str(value)
def _serialize_request_data(self, request_data: Any) -> dict[str, Any]:
"""Serialize RequestInfoMessage to dict for JSON transmission.
Handles nested SerializationMixin objects (like ChatMessage) within dataclasses.
Args:
request_data: The RequestInfoMessage instance
Returns:
Serialized dict representation
"""
from dataclasses import asdict, fields, is_dataclass
if request_data is None:
return {}
# Handle dict first (most common)
if isinstance(request_data, dict):
return {k: self._serialize_value(v) for k, v in request_data.items()}
# Handle dataclasses with nested SerializationMixin objects
# We can't use asdict() directly because it doesn't handle ChatMessage
if is_dataclass(request_data) and not isinstance(request_data, type):
try:
# Manually serialize each field to handle nested SerializationMixin
result = {}
for field in fields(request_data):
field_value = getattr(request_data, field.name)
result[field.name] = self._serialize_value(field_value)
return result
except Exception as e:
logger.debug(f"Failed to serialize dataclass fields: {e}")
# Fallback to asdict() if our custom serialization fails
try:
return asdict(request_data) # type: ignore[arg-type]
except Exception as e2:
logger.debug(f"Failed to serialize dataclass with asdict(): {e2}")
# Handle Pydantic models (have model_dump method)
if hasattr(request_data, "model_dump") and callable(getattr(request_data, "model_dump", None)):
try:
return request_data.model_dump() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize Pydantic model: {e}")
# Handle SerializationMixin (have to_dict method)
if hasattr(request_data, "to_dict") and callable(getattr(request_data, "to_dict", None)):
try:
return request_data.to_dict() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize with to_dict(): {e}")
# Fallback: string representation
return {"raw": str(request_data)}
async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]:
"""Convert agent text updates to proper content part events.
@@ -638,7 +782,65 @@ class MessageMapper:
return events
if event_class in ["WorkflowCompletedEvent", "WorkflowOutputEvent"]:
# Handle WorkflowOutputEvent separately to preserve output data
if event_class == "WorkflowOutputEvent":
output_data = getattr(event, "data", None)
source_executor_id = getattr(event, "source_executor_id", "unknown")
if output_data is not None:
# Import required types
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
# Increment output index for each yield_output
context["output_index"] = context.get("output_index", -1) + 1
# Extract text from output data based on type
text = None
if hasattr(output_data, "__class__") and output_data.__class__.__name__ == "ChatMessage":
# Handle ChatMessage (from Magentic and AgentExecutor with output_response=True)
text = getattr(output_data, "text", None)
if not text:
# Fallback to string representation
text = str(output_data)
elif isinstance(output_data, str):
# String output
text = output_data
else:
# Object/dict/list → JSON string
try:
text = json.dumps(output_data, indent=2)
except (TypeError, ValueError):
# Fallback to string representation if not JSON serializable
text = str(output_data)
# Create output message with text content
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
output_message = ResponseOutputMessage(
type="message",
id=f"msg_{uuid4().hex[:8]}",
role="assistant",
content=[text_content],
status="completed",
)
# Emit output_item.added for each yield_output
logger.debug(
f"WorkflowOutputEvent converted to output_item.added "
f"(executor: {source_executor_id}, length: {len(text)})"
)
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=output_message,
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
# Handle WorkflowCompletedEvent - emit response.completed
if event_class == "WorkflowCompletedEvent":
workflow_id = context.get("workflow_id", str(uuid4()))
# Import Response type for proper construction
@@ -654,7 +856,7 @@ class MessageMapper:
object="response",
created_at=float(time.time()),
model=model_name,
output=[], # Output should be populated by this point from text streaming
output=[], # Output items already sent via output_item.added events
status="completed",
parallel_tool_calls=False,
tool_choice="none",
@@ -781,8 +983,77 @@ class MessageMapper:
)
]
# Handle informational workflow events (status, warnings, errors)
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent", "RequestInfoEvent"]:
# Handle RequestInfoEvent specially - emit as HIL event with schema
if event_class == "RequestInfoEvent":
from .models._openai_custom import ResponseRequestInfoEvent
request_id = getattr(event, "request_id", "")
source_executor_id = getattr(event, "source_executor_id", "")
request_type_class = getattr(event, "request_type", None)
request_data = getattr(event, "data", None)
logger.info("📨 [MAPPER] Processing RequestInfoEvent")
logger.info(f" request_id: {request_id}")
logger.info(f" source_executor_id: {source_executor_id}")
logger.info(f" request_type_class: {request_type_class}")
logger.info(f" request_data: {request_data}")
# Serialize request data
serialized_data = self._serialize_request_data(request_data)
logger.info(f" serialized_data: {serialized_data}")
# Get request type name for debugging
request_type_name = "Unknown"
if request_type_class:
request_type_name = f"{request_type_class.__module__}:{request_type_class.__name__}"
# Get response schema that was attached by executor
# This tells the UI what format to collect from the user
response_schema = getattr(event, "_response_schema", None)
if not response_schema:
# Fallback to string if somehow not set (shouldn't happen with current executor enrichment)
logger.warning(f"⚠️ Response schema not found for {request_type_name}, using default")
response_schema = {"type": "string"}
else:
logger.info(f" response_schema: {response_schema}")
# Wrap primitive schemas in object for form rendering
# The UI's SchemaFormRenderer expects an object with properties
if response_schema.get("type") in ["string", "integer", "number", "boolean"]:
# Wrap primitive type in object with "response" field
wrapped_schema = {
"type": "object",
"properties": {"response": response_schema},
"required": ["response"],
}
logger.info(" wrapped primitive schema in object")
else:
wrapped_schema = response_schema
# Create HIL request event with response schema
hil_event = ResponseRequestInfoEvent(
type="response.request_info.requested",
request_id=request_id,
source_executor_id=source_executor_id,
request_type=request_type_name,
request_data=serialized_data,
request_schema=wrapped_schema, # Send wrapped schema for form rendering
response_schema=response_schema, # Keep original for reference
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
timestamp=datetime.now().isoformat(),
)
logger.info("✅ [MAPPER] Created ResponseRequestInfoEvent:")
logger.info(f" type: {hil_event.type}")
logger.info(f" request_id: {hil_event.request_id}")
logger.info(f" sequence_number: {hil_event.sequence_number}")
return [hil_event]
# Handle other informational workflow events (status, warnings, errors)
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent"]:
# These are informational events that don't map to OpenAI lifecycle events
# Convert them to trace events for debugging visibility
event_data: dict[str, Any] = {}
@@ -795,13 +1066,10 @@ class MessageMapper:
elif event_class == "WorkflowErrorEvent":
event_data["message"] = str(getattr(event, "message", ""))
event_data["error"] = str(getattr(event, "error", ""))
elif event_class == "RequestInfoEvent":
request_info = getattr(event, "data", {})
event_data["request_info"] = request_info if isinstance(request_info, dict) else str(request_info)
# Create a trace event for debugging
trace_event = ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"trace_type": "workflow_info",
"event_type": event_class,
@@ -816,6 +1084,237 @@ class MessageMapper:
return [trace_event]
# Handle Magentic-specific events
if event_class == "MagenticAgentDeltaEvent":
agent_id = getattr(event, "agent_id", "unknown_agent")
text = getattr(event, "text", None)
if text:
events = []
# Track Magentic agent messages separately from regular messages
# Use timestamp to ensure uniqueness for multiple runs of same agent
magentic_key = f"magentic_message_{agent_id}"
# Check if this is the first delta from this agent (need to create message container)
if magentic_key not in context:
# Create a unique message ID for this agent's streaming session
message_id = f"msg_{agent_id}_{uuid4().hex[:8]}"
context[magentic_key] = message_id
context["output_index"] = context.get("output_index", -1) + 1
# Import required types
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_content_part_added_event import (
ResponseContentPartAddedEvent,
)
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
# Emit message output item (container for the agent's message)
# This matches what _convert_agent_update does for regular agents
events.append(
ResponseOutputItemAddedEvent(
type="response.output_item.added",
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
item=ResponseOutputMessage(
type="message",
id=message_id,
role="assistant",
content=[],
status="in_progress",
# Add metadata to identify this as a Magentic agent message
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
),
)
)
# Add content part for text (establishes the text container)
events.append(
ResponseContentPartAddedEvent(
type="response.content_part.added",
output_index=context["output_index"],
content_index=0,
item_id=message_id,
sequence_number=self._next_sequence(context),
part=ResponseOutputText(type="output_text", text="", annotations=[]),
)
)
# Get the message ID for this agent
message_id = context[magentic_key]
# Emit text delta event using the message ID (matches regular agent behavior)
events.append(
ResponseTextDeltaEvent(
type="response.output_text.delta",
output_index=context["output_index"],
content_index=0, # Always 0 for single text content
item_id=message_id,
delta=text,
logprobs=[],
sequence_number=self._next_sequence(context),
)
)
return events
# Handle function calls from Magentic agents
if getattr(event, "function_call_id", None) and getattr(event, "function_call_name", None):
# Handle function call initiation
function_call_id = getattr(event, "function_call_id", None)
function_call_name = getattr(event, "function_call_name", None)
function_call_arguments = getattr(event, "function_call_arguments", None)
# Track function call for accumulating arguments
context["active_function_calls"][function_call_id] = {
"item_id": function_call_id,
"name": function_call_name,
"arguments_chunks": [],
}
# Emit function call output item
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseFunctionToolCall(
id=function_call_id,
call_id=function_call_id,
name=function_call_name,
arguments=json.dumps(function_call_arguments) if function_call_arguments else "",
type="function_call",
status="in_progress",
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
# For other non-text deltas, emit as trace for debugging
return [
ResponseTraceEventComplete(
type="response.trace.completed",
data={
"trace_type": "magentic_delta",
"agent_id": agent_id,
"function_call_id": getattr(event, "function_call_id", None),
"function_call_name": getattr(event, "function_call_name", None),
"function_result_id": getattr(event, "function_result_id", None),
"timestamp": datetime.now().isoformat(),
},
span_id=f"magentic_delta_{uuid4().hex[:8]}",
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
)
]
if event_class == "MagenticAgentMessageEvent":
agent_id = getattr(event, "agent_id", "unknown_agent")
message = getattr(event, "message", None)
# Track Magentic agent messages
magentic_key = f"magentic_message_{agent_id}"
# Check if we were streaming for this agent
if magentic_key in context:
# Mark the streaming message as complete
message_id = context[magentic_key]
# Import required types
from openai.types.responses import ResponseOutputMessage
from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent
# Extract text from ChatMessage for the completed message
text = None
if message and hasattr(message, "text"):
text = message.text
# Emit output_item.done to mark message as complete
events = [
ResponseOutputItemDoneEvent(
type="response.output_item.done",
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
item=ResponseOutputMessage(
type="message",
id=message_id,
role="assistant",
content=[], # Content already streamed via deltas
status="completed",
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
),
)
]
# Clean up context for this agent
del context[magentic_key]
logger.debug(f"MagenticAgentMessageEvent from {agent_id} marked streaming message as complete")
return events
# No streaming occurred, create a complete message (shouldn't happen normally)
# Extract text from ChatMessage
text = None
if message and hasattr(message, "text"):
text = message.text
if text:
# Emit as output item for this agent
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
context["output_index"] = context.get("output_index", -1) + 1
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
output_message = ResponseOutputMessage(
type="message",
id=f"msg_{agent_id}_{uuid4().hex[:8]}",
role="assistant",
content=[text_content],
status="completed",
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
)
logger.debug(
f"MagenticAgentMessageEvent from {agent_id} converted to output_item.added (non-streaming)"
)
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=output_message,
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
if event_class == "MagenticOrchestratorMessageEvent":
orchestrator_id = getattr(event, "orchestrator_id", "orchestrator")
message = getattr(event, "message", None)
kind = getattr(event, "kind", "unknown")
# Extract text from ChatMessage
text = None
if message and hasattr(message, "text"):
text = message.text
# Emit as trace event for orchestrator messages (typically task ledger, instructions)
return [
ResponseTraceEventComplete(
type="response.trace.completed",
data={
"trace_type": "magentic_orchestrator",
"orchestrator_id": orchestrator_id,
"kind": kind,
"text": text or str(message),
"timestamp": datetime.now().isoformat(),
},
span_id=f"magentic_orch_{uuid4().hex[:8]}",
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
)
]
# For unknown/legacy events, still emit as workflow event for backward compatibility
# Get event data and serialize if it's a SerializationMixin
raw_event_data = getattr(event, "data", None)
@@ -830,7 +1329,7 @@ class MessageMapper:
# Create structured workflow event (keeping for backward compatibility)
workflow_event = ResponseWorkflowEventComplete(
type="response.workflow_event.complete",
type="response.workflow_event.completed",
data={
"event_type": event.__class__.__name__,
"data": serialized_event_data,
@@ -1056,30 +1555,227 @@ class MessageMapper:
# 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."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "data",
"data": getattr(content, "data", None),
"mime_type": getattr(content, "mime_type", "application/octet-stream"),
"size_bytes": len(str(getattr(content, "data", ""))) if getattr(content, "data", None) else 0,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
async def _map_data_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map DataContent to proper output item (image/file/data) or fallback to trace.
Maps Agent Framework DataContent to appropriate output types:
- Images (image/*) → ResponseOutputImage
- Common files (pdf, audio, video) → ResponseOutputFile
- Generic data → ResponseOutputData
- Unknown/debugging content → ResponseTraceEventComplete (fallback)
"""
mime_type = getattr(content, "mime_type", "application/octet-stream")
item_id = f"item_{uuid.uuid4().hex[:16]}"
# Extract data/uri
data_value = getattr(content, "data", None)
uri_value = getattr(content, "uri", None)
# Handle images
if mime_type.startswith("image/"):
# Prefer URI, but create data URI from data if needed
if uri_value:
image_url = uri_value
elif data_value:
# Convert bytes to base64 data URI
import base64
if isinstance(data_value, bytes):
b64_data = base64.b64encode(data_value).decode("utf-8")
else:
b64_data = str(data_value)
image_url = f"data:{mime_type};base64,{b64_data}"
else:
# No data available, fallback to trace
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
id=item_id,
type="output_image",
image_url=image_url,
mime_type=mime_type,
alt_text=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle common file types
if mime_type in [
"application/pdf",
"audio/mp3",
"audio/wav",
"audio/m4a",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mpeg",
"video/mp4",
"video/webm",
]:
# Determine filename from mime type
ext = mime_type.split("/")[-1]
if ext == "mpeg":
ext = "mp3" # audio/mpeg → .mp3
filename = f"output.{ext}"
# Prefer URI
if uri_value:
file_url = uri_value
file_data = None
elif data_value:
# Convert bytes to base64
import base64
if isinstance(data_value, bytes):
b64_data = base64.b64encode(data_value).decode("utf-8")
else:
b64_data = str(data_value)
file_url = f"data:{mime_type};base64,{b64_data}"
file_data = b64_data
else:
# No data available, fallback to trace
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
id=item_id,
type="output_file",
filename=filename,
file_url=file_url,
file_data=file_data,
mime_type=mime_type,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle generic data (structured data, JSON, etc.)
data_str = ""
if uri_value:
data_str = uri_value
elif data_value:
if isinstance(data_value, bytes):
try:
data_str = data_value.decode("utf-8")
except UnicodeDecodeError:
# Binary data, encode as base64 for display
import base64
data_str = base64.b64encode(data_value).decode("utf-8")
else:
data_str = str(data_value)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputData( # type: ignore[arg-type]
id=item_id,
type="output_data",
data=data_str,
mime_type=mime_type,
description=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_uri_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map UriContent to structured trace event."""
async def _map_uri_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map UriContent to proper output item (image/file) based on MIME type.
UriContent has a URI and MIME type, so we can create appropriate output items:
- Images → ResponseOutputImage
- Common files → ResponseOutputFile
- Other URIs → ResponseTraceEventComplete (fallback for debugging)
"""
mime_type = getattr(content, "mime_type", "text/plain")
uri = getattr(content, "uri", "")
item_id = f"item_{uuid.uuid4().hex[:16]}"
if not uri:
# No URI available, fallback to trace
logger.warning("UriContent has no uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "uri", "mime_type": mime_type, "error": "No uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle images
if mime_type.startswith("image/"):
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
id=item_id,
type="output_image",
image_url=uri,
mime_type=mime_type,
alt_text=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle common file types
if mime_type in [
"application/pdf",
"audio/mp3",
"audio/wav",
"audio/m4a",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mpeg",
"video/mp4",
"video/webm",
]:
# Extract filename from URI or use generic name
filename = uri.split("/")[-1] if "/" in uri else f"output.{mime_type.split('/')[-1]}"
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
id=item_id,
type="output_file",
filename=filename,
file_url=uri,
file_data=None,
mime_type=mime_type,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# For other URI types (text/plain, application/json, etc.), use trace for now
logger.debug(f"UriContent with unsupported MIME type {mime_type}, using trace event")
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "uri",
"uri": getattr(content, "uri", ""),
"mime_type": getattr(content, "mime_type", "text/plain"),
"uri": uri,
"mime_type": mime_type,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
@@ -1088,9 +1784,15 @@ class MessageMapper:
)
async def _map_hosted_file_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map HostedFileContent to structured trace event."""
"""Map HostedFileContent to trace event.
HostedFileContent references external file IDs (like OpenAI file IDs).
These remain as traces since they're metadata about hosted resources,
not direct content to display. To display them, agents should return
DataContent or UriContent with the actual file data/URL.
"""
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "hosted_file",
"file_id": getattr(content, "file_id", "unknown"),
@@ -1104,9 +1806,14 @@ class MessageMapper:
async def _map_hosted_vector_store_content(
self, content: Any, context: dict[str, Any]
) -> ResponseTraceEventComplete:
"""Map HostedVectorStoreContent to structured trace event."""
"""Map HostedVectorStoreContent to trace event.
HostedVectorStoreContent references external vector store IDs.
These remain as traces since they're metadata about hosted resources,
not direct content to display.
"""
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "hosted_vector_store",
"vector_store_id": getattr(content, "vector_store_id", "unknown"),
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI integration for DevUI - proxy support for OpenAI Responses API."""
from ._executor import OpenAIExecutor
__all__ = [
"OpenAIExecutor",
]
@@ -0,0 +1,270 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI Executor - proxies requests to OpenAI Responses API.
This executor mirrors the AgentFrameworkExecutor interface but routes
requests to OpenAI's API instead of executing local entities.
"""
import logging
import os
from collections.abc import AsyncGenerator
from typing import Any
from openai import APIStatusError, AsyncOpenAI, AsyncStream, AuthenticationError, PermissionDeniedError, RateLimitError
from openai.types.responses import Response, ResponseStreamEvent
from .._conversations import ConversationStore
from ..models import AgentFrameworkRequest, OpenAIResponse
logger = logging.getLogger(__name__)
class OpenAIExecutor:
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
This executor provides the same interface as AgentFrameworkExecutor but proxies
requests to OpenAI's Responses API instead of executing local entities.
Key features:
- Same execute_streaming() and execute_sync() interface
- Shares ConversationStore with local executor
- Configured via OPENAI_API_KEY environment variable
- Supports all OpenAI Responses API parameters
"""
def __init__(self, conversation_store: ConversationStore):
"""Initialize OpenAI executor.
Args:
conversation_store: Shared conversation store (works for both local and OpenAI)
"""
self.conversation_store = conversation_store
# Load configuration from environment
self.api_key = os.getenv("OPENAI_API_KEY")
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
self._client: AsyncOpenAI | None = None
@property
def is_configured(self) -> bool:
"""Check if OpenAI executor is properly configured.
Returns:
True if OPENAI_API_KEY is set
"""
return self.api_key is not None
def _get_client(self) -> AsyncOpenAI:
"""Get or create OpenAI async client.
Returns:
AsyncOpenAI client instance
Raises:
ValueError: If OPENAI_API_KEY not configured
"""
if self._client is None:
if not self.api_key:
raise ValueError("OPENAI_API_KEY environment variable not set")
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
logger.debug(f"Created OpenAI client with base_url: {self.base_url}")
return self._client
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]:
"""Execute request via OpenAI and stream results in OpenAI format.
This mirrors AgentFrameworkExecutor.execute_streaming() interface.
Args:
request: Request to execute
Yields:
OpenAI ResponseStreamEvent objects (already in correct format!)
"""
if not self.is_configured:
logger.error("OpenAI executor not configured (missing OPENAI_API_KEY)")
# Emit proper response.failed event
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": "OpenAI not configured on server. Set OPENAI_API_KEY environment variable.",
"type": "configuration_error",
"code": "openai_not_configured",
},
},
}
return
try:
client = self._get_client()
# Convert AgentFrameworkRequest to OpenAI params
params = request.to_openai_params()
# Remove DevUI-specific fields that OpenAI doesn't recognize
params.pop("extra_body", None)
# Conversation ID is now from OpenAI (created via /v1/conversations proxy)
# so we can pass it through!
# Force streaming mode (remove if already present to avoid duplicate)
params.pop("stream", None)
logger.info(f"🔀 Proxying to OpenAI Responses API: model={params.get('model')}")
logger.debug(f"Request params: {params}")
# Call OpenAI Responses API - returns AsyncStream[ResponseStreamEvent]
stream: AsyncStream[ResponseStreamEvent] = await client.responses.create(
**params,
stream=True, # Force streaming
)
# Yield events directly - they're already ResponseStreamEvent objects!
# No conversion needed - OpenAI SDK returns proper typed objects
async for event in stream:
yield event
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "authentication_error"),
"code": error_data.get("code", "invalid_api_key"),
},
},
}
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "permission_denied"),
"code": error_data.get("code", "insufficient_permissions"),
},
},
}
except RateLimitError as e:
# 429 - Rate limit exceeded
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "rate_limit_error"),
"code": error_data.get("code", "rate_limit_exceeded"),
},
},
}
except APIStatusError as e:
# Other OpenAI API errors
logger.error(f"OpenAI API error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "api_error"),
"code": error_data.get("code", "unknown_error"),
},
},
}
except Exception as e:
# Catch-all for unexpected errors
logger.error(f"Unexpected error in OpenAI proxy: {e}", exc_info=True)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": f"Unexpected error: {e!s}",
"type": "internal_error",
"code": "unexpected_error",
},
},
}
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
"""Execute request via OpenAI and return complete response.
This mirrors AgentFrameworkExecutor.execute_sync() interface.
Args:
request: Request to execute
Returns:
Final OpenAI Response object
Raises:
ValueError: If OpenAI not configured
Exception: If OpenAI API call fails
"""
if not self.is_configured:
raise ValueError("OpenAI not configured on server. Set OPENAI_API_KEY environment variable.")
try:
client = self._get_client()
# Convert AgentFrameworkRequest to OpenAI params
params = request.to_openai_params()
# Remove DevUI-specific fields
params.pop("extra_body", None)
# Force non-streaming mode (remove if already present to avoid duplicate)
params.pop("stream", None)
logger.info(f"🔀 Proxying to OpenAI Responses API (non-streaming): model={params.get('model')}")
logger.debug(f"Request params: {params}")
# Call OpenAI Responses API - returns Response object
response: Response = await client.responses.create(
**params,
stream=False, # Force non-streaming
)
return response
except Exception as e:
logger.error(f"OpenAI proxy error: {e}", exc_info=True)
raise
async def close(self) -> None:
"""Close the OpenAI client and release resources."""
if self._client:
await self._client.close()
self._client = None
logger.debug("Closed OpenAI client")
@@ -5,7 +5,9 @@
import inspect
import json
import logging
from collections.abc import AsyncGenerator
import os
import secrets
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
@@ -14,15 +16,20 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from ._deployment import DeploymentManager
from ._discovery import EntityDiscovery
from ._executor import AgentFrameworkExecutor
from ._mapper import MessageMapper
from .models import AgentFrameworkRequest, OpenAIError
from .models._discovery_models import DiscoveryResponse, EntityInfo
from ._openai import OpenAIExecutor
from .models import AgentFrameworkRequest, MetaResponse, OpenAIError
from .models._discovery_models import Deployment, DeploymentConfig, DiscoveryResponse, EntityInfo
logger = logging.getLogger(__name__)
# No AuthMiddleware class needed - we'll use the decorator pattern instead
class DevServer:
"""Development Server - OpenAI compatible API server for debugging agents."""
@@ -33,6 +40,7 @@ class DevServer:
host: str = "127.0.0.1",
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
mode: str = "developer",
) -> None:
"""Initialize the development server.
@@ -42,16 +50,79 @@ class DevServer:
host: Host to bind server to
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
"""
self.entities_dir = entities_dir
self.port = port
self.host = host
self.cors_origins = cors_origins or ["*"]
# Smart CORS defaults: permissive for localhost, restrictive for network-exposed deployments
if cors_origins is None:
# Localhost development: allow cross-origin for dev tools (e.g., frontend dev server)
# Network-exposed: empty list (same-origin only, no CORS)
cors_origins = ["*"] if host in ("127.0.0.1", "localhost") else []
self.cors_origins = cors_origins
self.ui_enabled = ui_enabled
self.mode = mode
self.executor: AgentFrameworkExecutor | None = None
self.openai_executor: OpenAIExecutor | None = None
self.deployment_manager = DeploymentManager()
self._app: FastAPI | None = None
self._pending_entities: list[Any] | None = None
def _is_dev_mode(self) -> bool:
"""Check if running in developer mode.
Returns:
True if in developer mode, False if in user mode
"""
return self.mode == "developer"
def _format_error(self, error: Exception, context: str = "Operation") -> str:
"""Format error message based on server mode.
In developer mode: Returns detailed error message for debugging.
In user mode: Returns generic message and logs details internally.
Args:
error: The exception that occurred
context: Description of the operation that failed (e.g., "Request execution")
Returns:
Formatted error message appropriate for the current mode
"""
if self._is_dev_mode():
# Developer mode: Show full error details for debugging
return f"{context} failed: {error!s}"
# User mode: Generic message to user, detailed logging internally
logger.error(f"{context} failed: {error}", exc_info=True)
return f"{context} failed"
def _require_developer_mode(self, feature: str = "operation") -> None:
"""Check if current mode allows developer operations.
Args:
feature: Name of the feature being accessed (for error message)
Raises:
HTTPException: If in user mode
"""
if self.mode == "user":
logger.warning(f"Blocked {feature} access in user mode")
raise HTTPException(
status_code=403,
detail={
"error": {
"message": f"Access denied: {feature} requires developer mode",
"type": "permission_denied",
"code": "developer_mode_required",
"current_mode": self.mode,
}
},
)
async def _ensure_executor(self) -> AgentFrameworkExecutor:
"""Ensure executor is initialized."""
if self.executor is None:
@@ -84,6 +155,29 @@ class DevServer:
return self.executor
async def _ensure_openai_executor(self) -> OpenAIExecutor:
"""Ensure OpenAI executor is initialized.
Returns:
OpenAI executor instance
Raises:
ValueError: If OpenAI executor cannot be initialized
"""
if self.openai_executor is None:
# Initialize local executor first to get conversation_store
local_executor = await self._ensure_executor()
# Create OpenAI executor with shared conversation store
self.openai_executor = OpenAIExecutor(local_executor.conversation_store)
if self.openai_executor.is_configured:
logger.info("OpenAI proxy mode available (OPENAI_API_KEY configured)")
else:
logger.info("OpenAI proxy mode disabled (OPENAI_API_KEY not set)")
return self.openai_executor
async def _cleanup_entities(self) -> None:
"""Cleanup entity resources (close clients, MCP tools, credentials, etc.)."""
if not self.executor:
@@ -94,12 +188,28 @@ class DevServer:
closed_count = 0
mcp_tools_closed = 0
credentials_closed = 0
hook_count = 0
for entity_info in entities:
entity_id = entity_info.id
try:
entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
# Step 1: Execute registered cleanup hooks (NEW)
cleanup_hooks = self.executor.entity_discovery.get_cleanup_hooks(entity_id)
for hook in cleanup_hooks:
try:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
hook_count += 1
logger.debug(f"✓ Executed cleanup hook for: {entity_id}")
except Exception as e:
logger.warning(f"⚠ Cleanup hook failed for {entity_id}: {e}")
# Step 2: Close chat clients and their credentials (EXISTING)
entity_obj = self.executor.entity_discovery.get_entity_object(entity_id)
# Close chat clients and their credentials
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
@@ -144,14 +254,24 @@ class DevServer:
logger.warning(f"Error closing MCP tool for {entity_info.id}: {e}")
except Exception as e:
logger.warning(f"Error closing entity {entity_info.id}: {e}")
logger.warning(f"Error cleaning up entity {entity_id}: {e}")
if hook_count > 0:
logger.info(f"✓ Executed {hook_count} cleanup hook(s)")
if closed_count > 0:
logger.info(f"Closed {closed_count} entity client(s)")
logger.info(f"Closed {closed_count} entity client(s)")
if credentials_closed > 0:
logger.info(f"Closed {credentials_closed} credential(s)")
logger.info(f"Closed {credentials_closed} credential(s)")
if mcp_tools_closed > 0:
logger.info(f"Closed {mcp_tools_closed} MCP tool(s)")
logger.info(f"Closed {mcp_tools_closed} MCP tool(s)")
# Close OpenAI executor if it exists
if self.openai_executor:
try:
await self.openai_executor.close()
logger.info("Closed OpenAI executor")
except Exception as e:
logger.warning(f"Error closing OpenAI executor: {e}")
def create_app(self) -> FastAPI:
"""Create the FastAPI application."""
@@ -161,6 +281,7 @@ class DevServer:
# Startup
logger.info("Starting Agent Framework Server")
await self._ensure_executor()
await self._ensure_openai_executor() # Initialize OpenAI executor
yield
# Shutdown
logger.info("Shutting down Agent Framework Server")
@@ -177,14 +298,74 @@ class DevServer:
)
# Add CORS middleware
# Note: allow_credentials cannot be True when allow_origins is ["*"]
# For localhost dev with wildcard origins, credentials are disabled
# For network deployments with specific origins or empty list, credentials can be enabled
allow_credentials = self.cors_origins != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=self.cors_origins,
allow_credentials=True,
allow_credentials=allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
# Add authentication middleware using decorator pattern
# Auth is enabled by presence of DEVUI_AUTH_TOKEN
auth_token = os.getenv("DEVUI_AUTH_TOKEN", "")
auth_required = bool(auth_token)
if auth_required:
logger.info("Authentication middleware enabled")
@app.middleware("http")
async def auth_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any:
"""Validate Bearer token authentication.
Skips authentication for health, meta, static UI endpoints, and OPTIONS requests.
"""
# Skip auth for OPTIONS (CORS preflight) requests
if request.method == "OPTIONS":
return await call_next(request)
# Skip auth for health checks, meta endpoint, and static files
if request.url.path in ["/health", "/meta", "/"] or request.url.path.startswith("/assets"):
return await call_next(request)
# Check Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={
"error": {
"message": (
"Missing or invalid Authorization header. Expected: Authorization: Bearer <token>"
),
"type": "authentication_error",
"code": "missing_token",
}
},
)
# Extract and validate token
token = auth_header.replace("Bearer ", "", 1).strip()
if not secrets.compare_digest(token, auth_token):
return JSONResponse(
status_code=401,
content={
"error": {
"message": "Invalid authentication token",
"type": "authentication_error",
"code": "invalid_token",
}
},
)
# Token valid, proceed
return await call_next(request)
self._register_routes(app)
self._mount_ui(app)
@@ -202,6 +383,28 @@ class DevServer:
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
@app.get("/meta", response_model=MetaResponse)
async def get_meta() -> MetaResponse:
"""Get server metadata and configuration."""
import os
from . import __version__
# Ensure executors are initialized to check capabilities
openai_executor = await self._ensure_openai_executor()
return MetaResponse(
ui_mode=self.mode, # type: ignore[arg-type]
version=__version__,
framework="agent_framework",
capabilities={
"tracing": os.getenv("ENABLE_OTEL") == "true",
"openai_proxy": openai_executor.is_configured,
"deployment": True, # Deployment feature is available
},
auth_required=bool(os.getenv("DEVUI_AUTH_TOKEN")),
)
@app.get("/v1/entities", response_model=DiscoveryResponse)
async def discover_entities() -> DiscoveryResponse:
"""List all registered entities."""
@@ -226,7 +429,10 @@ class DevServer:
# Trigger lazy loading if entity not yet loaded
# This will import the module and enrich metadata
entity_obj = await executor.entity_discovery.load_entity(entity_id)
# Pass checkpoint_manager to ensure workflows get checkpoint storage injected
entity_obj = await executor.entity_discovery.load_entity(
entity_id, checkpoint_manager=executor.checkpoint_manager
)
# Get updated entity info (may have been enriched during load)
entity_info = executor.get_entity_info(entity_id) or entity_info
@@ -305,6 +511,7 @@ class DevServer:
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
# Create copy of entity info and populate workflow-specific fields
# Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
update_payload: dict[str, Any] = {
"workflow_dump": workflow_dump,
"input_schema": input_schema,
@@ -320,9 +527,13 @@ class DevServer:
except HTTPException:
raise
except ValueError as e:
# ValueError from load_entity indicates entity not found or invalid
error_msg = self._format_error(e, "Entity loading")
raise HTTPException(status_code=404, detail=error_msg) from e
except Exception as e:
logger.error(f"Error getting entity info for {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e
error_msg = self._format_error(e, "Entity info retrieval")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.post("/v1/entities/{entity_id}/reload")
async def reload_entity(entity_id: str) -> dict[str, Any]:
@@ -331,6 +542,7 @@ class DevServer:
This enables hot reload during development - edit entity code, call this endpoint,
and the next execution will use the updated code without server restart.
"""
self._require_developer_mode("entity hot reload")
try:
executor = await self._ensure_executor()
@@ -353,10 +565,140 @@ class DevServer:
logger.error(f"Error reloading entity {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to reload entity: {e!s}") from e
# ============================================================================
# Deployment Endpoints
# ============================================================================
@app.post("/v1/deployments")
async def create_deployment(config: DeploymentConfig) -> StreamingResponse:
"""Deploy entity to Azure Container Apps with streaming events.
Returns SSE stream of deployment progress events.
"""
self._require_developer_mode("deployment")
try:
executor = await self._ensure_executor()
# Validate entity exists and supports deployment
entity_info = executor.get_entity_info(config.entity_id)
if not entity_info:
raise HTTPException(status_code=404, detail=f"Entity {config.entity_id} not found")
if not entity_info.deployment_supported:
reason = entity_info.deployment_reason or "Deployment not supported for this entity"
raise HTTPException(status_code=400, detail=reason)
# Get entity path from metadata
from pathlib import Path
entity_path_str = entity_info.metadata.get("path")
if not entity_path_str:
raise HTTPException(
status_code=400,
detail="Entity path not found in metadata (in-memory entities cannot be deployed)",
)
entity_path = Path(entity_path_str)
# Stream deployment events
async def event_generator() -> AsyncGenerator[str, None]:
async for event in self.deployment_manager.deploy(config, entity_path):
# Format as SSE
import json
yield f"data: {json.dumps(event.model_dump())}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
except HTTPException:
raise
except Exception as e:
error_msg = self._format_error(e, "Deployment creation")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.get("/v1/deployments")
async def list_deployments(entity_id: str | None = None) -> list[Deployment]:
"""List all deployments, optionally filtered by entity."""
self._require_developer_mode("deployment listing")
try:
return await self.deployment_manager.list_deployments(entity_id)
except Exception as e:
error_msg = self._format_error(e, "Deployment listing")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.get("/v1/deployments/{deployment_id}")
async def get_deployment(deployment_id: str) -> Deployment:
"""Get deployment by ID."""
self._require_developer_mode("deployment details")
try:
deployment = await self.deployment_manager.get_deployment(deployment_id)
if not deployment:
raise HTTPException(status_code=404, detail=f"Deployment {deployment_id} not found")
return deployment
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting deployment: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get deployment: {e!s}") from e
@app.delete("/v1/deployments/{deployment_id}")
async def delete_deployment(deployment_id: str) -> dict[str, Any]:
"""Delete deployment from Azure Container Apps."""
self._require_developer_mode("deployment deletion")
try:
await self.deployment_manager.delete_deployment(deployment_id)
return {"success": True, "message": f"Deployment {deployment_id} deleted successfully"}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e:
logger.error(f"Error deleting deployment: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete deployment: {e!s}") from e
# Convenience endpoint: deploy specific entity
@app.post("/v1/entities/{entity_id}/deploy")
async def deploy_entity(entity_id: str, config: DeploymentConfig) -> StreamingResponse:
"""Convenience endpoint to deploy entity (shortcuts to /v1/deployments)."""
self._require_developer_mode("deployment")
# Override entity_id from path parameter
config.entity_id = entity_id
return await create_deployment(config)
# ============================================================================
# Response/Conversation Endpoints
# ============================================================================
@app.post("/v1/responses")
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
"""OpenAI Responses API endpoint."""
"""OpenAI Responses API endpoint - routes to local or OpenAI executor."""
try:
# Check if frontend requested OpenAI proxy mode
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
if proxy_mode == "openai":
# Route to OpenAI executor
logger.info("🔀 Routing to OpenAI proxy mode")
openai_executor = await self._ensure_openai_executor()
if not openai_executor.is_configured:
error = OpenAIError.create(
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable."
)
return JSONResponse(status_code=503, content=error.to_dict())
# Execute via OpenAI with dedicated streaming method
if request.stream:
return StreamingResponse(
self._stream_openai_execution(openai_executor, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
},
)
return await openai_executor.execute_sync(request)
# Route to local Agent Framework executor (original behavior)
raw_body = await raw_request.body()
logger.info(f"Raw request body: {raw_body.decode()}")
logger.info(f"Parsed request: metadata={request.metadata}")
@@ -392,18 +734,86 @@ class DevServer:
return await executor.execute_sync(request)
except Exception as e:
logger.error(f"Error executing request: {e}")
error = OpenAIError.create(f"Execution failed: {e!s}")
error_msg = self._format_error(e, "Request execution")
error = OpenAIError.create(error_msg)
return JSONResponse(status_code=500, content=error.to_dict())
# ========================================
# 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."""
@app.post("/v1/conversations", response_model=None)
async def create_conversation(raw_request: Request) -> dict[str, Any] | JSONResponse:
"""Create a new conversation - routes to OpenAI or local based on mode."""
try:
# Parse request body
request_data = await raw_request.json()
# Check if frontend requested OpenAI proxy mode
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
if proxy_mode == "openai":
# Create conversation in OpenAI
openai_executor = await self._ensure_openai_executor()
if not openai_executor.is_configured:
error = OpenAIError.create(
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable.",
type="configuration_error",
code="openai_not_configured",
)
return JSONResponse(status_code=503, content=error.to_dict())
# Use OpenAI client to create conversation
from openai import APIStatusError, AsyncOpenAI, AuthenticationError, PermissionDeniedError
client = AsyncOpenAI(
api_key=openai_executor.api_key,
base_url=openai_executor.base_url,
)
try:
metadata = request_data.get("metadata")
logger.debug(f"Creating OpenAI conversation with metadata: {metadata}")
conversation = await client.conversations.create(metadata=metadata)
logger.info(f"Created OpenAI conversation: {conversation.id}")
return conversation.model_dump()
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "authentication_error"),
code=error_data.get("code", "invalid_api_key"),
)
return JSONResponse(status_code=401, content=error.to_dict())
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "permission_denied"),
code=error_data.get("code", "insufficient_permissions"),
)
return JSONResponse(status_code=403, content=error.to_dict())
except APIStatusError as e:
# Other OpenAI API errors (rate limit, etc.)
logger.error(f"OpenAI API error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "api_error"),
code=error_data.get("code", "unknown_error"),
)
return JSONResponse(
status_code=e.status_code if hasattr(e, "status_code") else 500, content=error.to_dict()
)
# Local mode - use DevUI conversation store
metadata = request_data.get("metadata")
executor = await self._ensure_executor()
conversation = executor.conversation_store.create_conversation(metadata=metadata)
@@ -411,22 +821,39 @@ class DevServer:
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
logger.error(f"Error creating conversation: {e}", exc_info=True)
error = OpenAIError.create(f"Failed to create conversation: {e!s}")
return JSONResponse(status_code=500, content=error.to_dict())
@app.get("/v1/conversations")
async def list_conversations(agent_id: str | None = None) -> dict[str, Any]:
"""List conversations, optionally filtered by agent_id."""
async def list_conversations(
agent_id: str | None = None,
entity_id: str | None = None,
type: str | None = None,
) -> dict[str, Any]:
"""List conversations, optionally filtered by agent_id, entity_id, and/or type.
Query Parameters:
- agent_id: Filter by agent_id (for agent conversations)
- entity_id: Filter by entity_id (for workflow sessions or other entities)
- type: Filter by conversation type (e.g., "workflow_session")
Multiple filters can be combined (AND logic).
"""
try:
executor = await self._ensure_executor()
# Build filter criteria
filters = {}
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({})
filters["agent_id"] = agent_id
if entity_id:
filters["entity_id"] = entity_id
if type:
filters["type"] = type
# Apply filters
conversations = executor.conversation_store.list_conversations_by_metadata(filters)
return {
"object": "list",
@@ -511,9 +938,20 @@ class DevServer:
items, has_more = await executor.conversation_store.list_items(
conversation_id, limit=limit, after=after, order=order
)
# Handle both Pydantic models and dicts (some stores return raw dicts)
serialized_items = []
for item in items:
if hasattr(item, "model_dump"):
serialized_items.append(item.model_dump())
elif isinstance(item, dict):
serialized_items.append(item)
else:
logger.warning(f"Unexpected item type: {type(item)}, converting to dict")
serialized_items.append(dict(item))
return {
"object": "list",
"data": [item.model_dump() for item in items],
"data": serialized_items,
"has_more": has_more,
}
except ValueError as e:
@@ -532,13 +970,51 @@ class DevServer:
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()
result: dict[str, Any] = item.model_dump()
return result
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
@app.delete("/v1/conversations/{conversation_id}/items/{item_id}")
async def delete_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]:
"""Delete conversation item - supports checkpoint deletion."""
try:
executor = await self._ensure_executor()
# Check if this is a checkpoint item
if item_id.startswith("checkpoint_"):
# Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}")
checkpoint_id = item_id[len("checkpoint_") :]
storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id)
deleted = await storage.delete_checkpoint(checkpoint_id)
if not deleted:
raise HTTPException(status_code=404, detail="Checkpoint not found")
return {
"id": item_id,
"object": "item.deleted",
"deleted": True,
}
# For other items, delegate to conversation store (if it supports deletion)
raise HTTPException(status_code=501, detail="Deletion of non-checkpoint items not implemented")
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e:
logger.error(f"Error deleting item {item_id} from conversation {conversation_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete item: {e!s}") from e
# ============================================================================
# Checkpoint Management - Now handled through conversation items API
# Checkpoints are exposed as conversation items with type="checkpoint"
# ============================================================================
async def _stream_execution(
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str, None]:
@@ -587,6 +1063,63 @@ class DevServer:
error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
yield f"data: {json.dumps(error_event)}\n\n"
async def _stream_openai_execution(
self, executor: OpenAIExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str, None]:
"""Stream execution through OpenAI executor.
OpenAI events are already in final format - no conversion or aggregation needed.
Just serialize and stream them as SSE.
Args:
executor: OpenAI executor instance
request: Request to execute
Yields:
SSE-formatted event strings
"""
try:
# Stream events from OpenAI - they're already ResponseStreamEvent objects
async for event in executor.execute_streaming(request):
# Handle error dicts from executor
if isinstance(event, dict):
payload = json.dumps(event)
yield f"data: {payload}\n\n"
continue
# OpenAI SDK events have model_dump_json() - use it for single-line JSON
if hasattr(event, "model_dump_json"):
payload = event.model_dump_json() # type: ignore[attr-defined]
yield f"data: {payload}\n\n"
else:
# Fallback (shouldn't happen with OpenAI SDK)
logger.warning(f"Unexpected event type from OpenAI: {type(event)}")
payload = json.dumps(str(event))
yield f"data: {payload}\n\n"
# OpenAI already sends response.completed event - no aggregation needed!
# Just send [DONE] marker
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Error in OpenAI streaming execution: {e}", exc_info=True)
# Emit proper response.failed event
import os
error_event = {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": str(e),
"type": "internal_error",
"code": "streaming_error",
},
},
}
yield f"data: {json.dumps(error_event)}\n\n"
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
from pathlib import Path
@@ -324,6 +324,71 @@ def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
return schema
def extract_response_type_from_executor(executor: Any, request_type: type) -> type | None:
"""Extract the expected response type from an executor's response handler.
Looks for methods decorated with @response_handler that have signature:
async def handler(self, original_request: RequestType, response: ResponseType, ctx)
Args:
executor: Executor object that should have a handler for the request type
request_type: The request message type
Returns:
The response type class, or None if not found
"""
try:
from typing import get_type_hints
# Introspect handler methods for @response_handler pattern
for attr_name in dir(executor):
if attr_name.startswith("_"):
continue
attr = getattr(executor, attr_name, None)
if not callable(attr):
continue
# Get type hints for this method
try:
type_hints = get_type_hints(attr)
# Check for @response_handler pattern:
# async def handler(self, original_request: RequestType, response: ResponseType, ctx)
type_hint_params = {k: v for k, v in type_hints.items() if k not in ("self", "return")}
# Look for at least 2 parameters: original_request, response (ctx is optional)
if len(type_hint_params) >= 2:
param_items = list(type_hint_params.items())
# First param should be original_request matching request_type
_, first_param_type = param_items[0]
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
# Check if first param matches request_type
first_matches_request = first_param_type == request_type or (
hasattr(first_param_type, "__name__")
and hasattr(request_type, "__name__")
and first_param_type.__name__ == request_type.__name__
)
# Verify we have a matching request type and valid response type (must be a type class)
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
response_type_class: type = second_param_type
logger.debug(
f"Found response type {response_type_class} for request {request_type} "
f"via @response_handler"
)
return response_type_class
except Exception as e:
logger.debug(f"Failed to get type hints for {attr_name}: {e}")
continue
except Exception as e:
logger.debug(f"Failed to extract response type from executor: {e}")
return None
def generate_input_schema(input_type: type) -> dict[str, Any]:
"""Generate JSON schema for workflow input type.
@@ -27,14 +27,18 @@ from openai.types.responses import (
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from openai.types.shared import Metadata, ResponsesModel
from ._discovery_models import DiscoveryResponse, EntityInfo
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
from ._openai_custom import (
AgentFrameworkRequest,
CustomResponseOutputItemAddedEvent,
CustomResponseOutputItemDoneEvent,
ExecutorActionItem,
MetaResponse,
OpenAIError,
ResponseFunctionResultComplete,
ResponseOutputData,
ResponseOutputFile,
ResponseOutputImage,
ResponseTraceEvent,
ResponseTraceEventComplete,
ResponseWorkflowEventComplete,
@@ -51,10 +55,14 @@ __all__ = [
"ConversationItem",
"CustomResponseOutputItemAddedEvent",
"CustomResponseOutputItemDoneEvent",
"Deployment",
"DeploymentConfig",
"DeploymentEvent",
"DiscoveryResponse",
"EntityInfo",
"ExecutorActionItem",
"InputTokensDetails",
"MetaResponse",
"Metadata",
"OpenAIError",
"OpenAIResponse",
@@ -67,6 +75,9 @@ __all__ = [
"ResponseFunctionToolCall",
"ResponseFunctionToolCallOutputItem",
"ResponseInputParam",
"ResponseOutputData",
"ResponseOutputFile",
"ResponseOutputImage",
"ResponseOutputItemAddedEvent",
"ResponseOutputItemDoneEvent",
"ResponseOutputMessage",
@@ -4,9 +4,10 @@
from __future__ import annotations
import re
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class EnvVarRequirement(BaseModel):
@@ -36,6 +37,10 @@ class EntityInfo(BaseModel):
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
# Deployment support
deployment_supported: bool = False # Whether entity can be deployed
deployment_reason: str | None = None # Explanation of why/why not entity can be deployed
# Agent-specific fields (optional, populated when available)
instructions: str | None = None
model_id: str | None = None
@@ -55,3 +60,144 @@ class DiscoveryResponse(BaseModel):
"""Response model for entity discovery."""
entities: list[EntityInfo] = Field(default_factory=list)
# ============================================================================
# Deployment Models
# ============================================================================
class DeploymentConfig(BaseModel):
"""Configuration for deploying an entity."""
entity_id: str = Field(description="Entity ID to deploy")
resource_group: str = Field(description="Azure resource group name")
app_name: str = Field(description="Azure Container App name")
region: str = Field(default="eastus", description="Azure region")
ui_mode: str = Field(default="user", description="UI mode (user or developer)")
ui_enabled: bool = Field(default=True, description="Whether to enable web interface")
stream: bool = Field(default=True, description="Stream deployment events")
@field_validator("app_name")
@classmethod
def validate_app_name(cls, v: str) -> str:
"""Validate Azure Container App name format.
Azure Container App names must:
- Be 3-32 characters long
- Contain only lowercase letters, numbers, and hyphens
- Start with a lowercase letter
- End with a lowercase letter or number
- Not contain consecutive hyphens
"""
if not v:
raise ValueError("app_name cannot be empty")
if len(v) < 3 or len(v) > 32:
raise ValueError("app_name must be between 3 and 32 characters")
if not re.match(r"^[a-z][a-z0-9-]*[a-z0-9]$", v):
raise ValueError(
"app_name must start with a lowercase letter, "
"end with a letter or number, and contain only lowercase letters, numbers, and hyphens"
)
if "--" in v:
raise ValueError("app_name cannot contain consecutive hyphens")
return v
@field_validator("resource_group")
@classmethod
def validate_resource_group(cls, v: str) -> str:
"""Validate Azure resource group name format.
Azure resource group names must:
- Be 1-90 characters long
- Contain only alphanumeric, underscore, parentheses, hyphen, period (except at end)
- Not end with a period
"""
if not v:
raise ValueError("resource_group cannot be empty")
if len(v) > 90:
raise ValueError("resource_group must be 90 characters or less")
if not re.match(r"^[a-zA-Z0-9._()-]+$", v):
raise ValueError(
"resource_group can only contain alphanumeric characters, "
"underscores, hyphens, periods, and parentheses"
)
if v.endswith("."):
raise ValueError("resource_group cannot end with a period")
return v
@field_validator("region")
@classmethod
def validate_region(cls, v: str) -> str:
"""Validate Azure region format.
Validates that the region string is a reasonable format.
Does not validate against the full list of Azure regions (which changes).
"""
if not v:
raise ValueError("region cannot be empty")
if len(v) > 50:
raise ValueError("region name too long")
# Azure regions are typically lowercase with no spaces (e.g., eastus, westeurope)
if not re.match(r"^[a-z0-9]+$", v):
raise ValueError("region must contain only lowercase letters and numbers (e.g., eastus, westeurope)")
return v
@field_validator("entity_id")
@classmethod
def validate_entity_id(cls, v: str) -> str:
"""Validate entity_id format to prevent injection attacks."""
if not v:
raise ValueError("entity_id cannot be empty")
if len(v) > 256:
raise ValueError("entity_id too long")
# Allow alphanumeric, hyphens, underscores, and periods
if not re.match(r"^[a-zA-Z0-9._-]+$", v):
raise ValueError("entity_id contains invalid characters")
return v
@field_validator("ui_mode")
@classmethod
def validate_ui_mode(cls, v: str) -> str:
"""Validate ui_mode is one of the allowed values."""
if v not in ("user", "developer"):
raise ValueError("ui_mode must be 'user' or 'developer'")
return v
class DeploymentEvent(BaseModel):
"""Real-time deployment event (SSE)."""
type: str = Field(description="Event type (e.g., deploy.validating, deploy.building)")
message: str = Field(description="Human-readable message")
url: str | None = Field(default=None, description="Deployment URL (on completion)")
auth_token: str | None = Field(default=None, description="Auth token (on completion, shown once)")
class Deployment(BaseModel):
"""Deployment record."""
id: str = Field(description="Deployment ID (UUID)")
entity_id: str = Field(description="Entity ID that was deployed")
resource_group: str = Field(description="Azure resource group")
app_name: str = Field(description="Azure Container App name")
region: str = Field(description="Azure region")
url: str = Field(description="Deployment URL")
status: str = Field(description="Deployment status (deploying, deployed, failed)")
created_at: str = Field(description="ISO 8601 timestamp")
error: str | None = Field(default=None, description="Error message if failed")
@@ -80,9 +80,16 @@ class CustomResponseOutputItemDoneEvent(BaseModel):
class ResponseWorkflowEventComplete(BaseModel):
"""Complete workflow event data."""
"""Complete workflow event data.
type: Literal["response.workflow_event.complete"] = "response.workflow_event.complete"
DevUI extension for workflow execution events (debugging/observability).
Uses past-tense 'completed' to follow OpenAI's event naming pattern.
Workflow events are shown in the debug panel for monitoring execution flow,
not in main chat. Use response.output_item.added for user-facing content.
"""
type: Literal["response.workflow_event.completed"] = "response.workflow_event.completed"
data: dict[str, Any] # Complete event data, not delta
executor_id: str | None = None
item_id: str
@@ -91,9 +98,17 @@ class ResponseWorkflowEventComplete(BaseModel):
class ResponseTraceEventComplete(BaseModel):
"""Complete trace event data."""
"""Complete trace event data.
type: Literal["response.trace.complete"] = "response.trace.complete"
DevUI extension for non-displayable debugging/metadata events.
Uses past-tense 'completed' to follow OpenAI's event naming pattern
(e.g., response.completed, response.output_item.added).
Trace events are shown in the Traces debug panel, not in main chat.
Use response.output_item.added for user-facing content.
"""
type: Literal["response.trace.completed"] = "response.trace.completed"
data: dict[str, Any] # Complete trace data, not delta
span_id: str | None = None
item_id: str
@@ -124,6 +139,139 @@ class ResponseFunctionResultComplete(BaseModel):
timestamp: str | None = None # Optional timestamp for UI display
class ResponseRequestInfoEvent(BaseModel):
"""DevUI extension: Workflow requests human input.
This is a DevUI extension because:
- OpenAI Responses API doesn't have a concept of workflow human-in-the-loop pausing
- Agent Framework workflows can pause via RequestInfoExecutor to collect external information
- Clients need to render forms and submit responses to continue workflow execution
When a workflow emits this event, it enters IDLE_WITH_PENDING_REQUESTS state.
Client should render a form based on request_schema and submit responses via
a new request with workflow_hil_response content type.
"""
type: Literal["response.request_info.requested"] = "response.request_info.requested"
request_id: str
"""Unique identifier for correlating this request with the response."""
source_executor_id: str
"""ID of the executor that is waiting for this response."""
request_type: str
"""Fully qualified type name of the request (e.g., 'module.path:ClassName')."""
request_data: dict[str, Any]
"""Current data from the RequestInfoMessage (may contain defaults/context)."""
request_schema: dict[str, Any]
"""JSON schema describing the request data structure (what the workflow is asking about)."""
response_schema: dict[str, Any] | None = None
"""JSON schema describing the expected response structure for form rendering (what user should provide)."""
item_id: str
"""OpenAI item ID for correlation."""
output_index: int = 0
"""Output index for OpenAI compatibility."""
sequence_number: int
"""Sequence number for ordering events."""
timestamp: str
"""ISO timestamp when the request was made."""
# DevUI Output Content Types - for agent-generated media/data
# These extend ResponseOutputItem to support rich content outputs that OpenAI's API doesn't natively support
class ResponseOutputImage(BaseModel):
"""DevUI extension: Agent-generated image output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- ImageGenerationCall exists but is for tool calls (generating images), not returning existing images
- Agent Framework agents can return images via DataContent/UriContent that need proper display
This type allows images to be displayed inline in chat rather than hidden in trace logs.
"""
id: str
"""The unique ID of the image output."""
image_url: str
"""The URL or data URI of the image (e.g., data:image/png;base64,...)"""
type: Literal["output_image"] = "output_image"
"""The type of the output. Always `output_image`."""
alt_text: str | None = None
"""Optional alt text for accessibility."""
mime_type: str = "image/png"
"""The MIME type of the image (e.g., image/png, image/jpeg)."""
class ResponseOutputFile(BaseModel):
"""DevUI extension: Agent-generated file output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- Agent Framework agents can return files via DataContent/UriContent that need proper display
- Supports PDFs, audio files, and other media types
This type allows files to be displayed inline in chat with appropriate renderers.
"""
id: str
"""The unique ID of the file output."""
filename: str
"""The filename (used to determine rendering and download)."""
type: Literal["output_file"] = "output_file"
"""The type of the output. Always `output_file`."""
file_url: str | None = None
"""Optional URL to the file."""
file_data: str | None = None
"""Optional base64-encoded file data."""
mime_type: str = "application/octet-stream"
"""The MIME type of the file (e.g., application/pdf, audio/mp3)."""
class ResponseOutputData(BaseModel):
"""DevUI extension: Agent-generated generic data output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- Agent Framework agents can return arbitrary structured data that needs display
- Useful for debugging and displaying non-text content
This type allows generic data to be displayed inline in chat.
"""
id: str
"""The unique ID of the data output."""
data: str
"""The data payload (string representation)."""
type: Literal["output_data"] = "output_data"
"""The type of the output. Always `output_data`."""
mime_type: str
"""The MIME type of the data."""
description: str | None = None
"""Optional description of the data."""
# Agent Framework extension fields
class AgentFrameworkExtraBody(BaseModel):
"""Agent Framework specific routing fields for OpenAI requests."""
@@ -156,8 +304,12 @@ class AgentFrameworkRequest(BaseModel):
metadata: dict[str, Any] | None = None
temperature: float | None = None
max_output_tokens: int | None = None
top_p: float | None = None
tools: list[dict[str, Any]] | None = None
# Reasoning parameters (for o-series models)
reasoning: dict[str, Any] | None = None # {"effort": "low" | "medium" | "high" | "minimal"}
# Optional extra_body for advanced use cases
extra_body: dict[str, Any] | None = None
@@ -219,11 +371,37 @@ class OpenAIError(BaseModel):
return self.model_dump_json()
class MetaResponse(BaseModel):
"""Server metadata response for /meta endpoint.
Provides information about the DevUI server configuration and capabilities.
"""
ui_mode: Literal["developer", "user"] = "developer"
"""UI interface mode - 'developer' shows debug tools, 'user' shows simplified interface."""
version: str
"""DevUI version string."""
framework: str = "agent_framework"
"""Backend framework identifier."""
capabilities: dict[str, bool] = {}
"""Server capabilities (e.g., tracing, openai_proxy)."""
auth_required: bool = False
"""Whether the server requires Bearer token authentication."""
# Export all custom types
__all__ = [
"AgentFrameworkRequest",
"MetaResponse",
"OpenAIError",
"ResponseFunctionResultComplete",
"ResponseOutputData",
"ResponseOutputFile",
"ResponseOutputImage",
"ResponseTraceEvent",
"ResponseTraceEventComplete",
"ResponseWorkflowEventComplete",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,7 +15,9 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.12",
"@xyflow/react": "^12.8.4",
+272 -82
View File
@@ -3,33 +3,49 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useState } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
import { WorkflowView } from "@/components/features/workflow";
import { Toast } from "@/components/ui/toast";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
export default function App() {
// Local state for auth handling
const [authRequired, setAuthRequired] = useState(false);
const [authToken, setAuthToken] = useState("");
const [isTestingToken, setIsTestingToken] = useState(false);
const [authError, setAuthError] = useState("");
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
const workflows = useDevUIStore((state) => state.workflows);
const entities = useDevUIStore((state) => state.entities);
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
const entityError = useDevUIStore((state) => state.entityError);
// OpenAI proxy mode
const oaiMode = useDevUIStore((state) => state.oaiMode);
// UI mode
const uiMode = useDevUIStore((state) => state.uiMode);
// Entity actions
const setAgents = useDevUIStore((state) => state.setAgents);
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
const setEntities = useDevUIStore((state) => state.setEntities);
const selectEntity = useDevUIStore((state) => state.selectEntity);
const updateAgent = useDevUIStore((state) => state.updateAgent);
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
@@ -38,12 +54,14 @@ export default function App() {
// UI state from Zustand
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
const debugEvents = useDevUIStore((state) => state.debugEvents);
const isResizing = useDevUIStore((state) => state.isResizing);
// UI actions
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
@@ -61,13 +79,39 @@ export default function App() {
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Toast state and actions
const toasts = useDevUIStore((state) => state.toasts);
const removeToast = useDevUIStore((state) => state.removeToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// Single API call instead of two parallel calls to same endpoint
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
// Fetch server metadata first (ui_mode, capabilities, auth status)
const meta = await apiClient.getMeta();
// Check if auth is required
if (meta.auth_required) {
setAuthRequired(true);
// If we don't have a token, stop here and show auth UI
if (!apiClient.getAuthToken()) {
setEntityError("UNAUTHORIZED");
setIsLoadingEntities(false);
return;
}
}
useDevUIStore.getState().setServerMeta({
uiMode: meta.ui_mode,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
});
// Single API call instead of two parallel calls to same endpoint
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setEntities(allEntities);
setAgents(agentList);
setWorkflows(workflowList);
@@ -79,9 +123,7 @@ export default function App() {
// Try to find entity from URL parameter first
if (entityId) {
selectedEntity =
agentList.find((a) => a.id === entityId) ||
workflowList.find((w) => w.id === entityId);
selectedEntity = allEntities.find((e) => e.id === entityId);
// If entity not found but was requested, show notification
if (!selectedEntity) {
@@ -91,12 +133,9 @@ export default function App() {
// Fallback to first available entity if URL entity not found
if (!selectedEntity) {
selectedEntity =
agentList.length > 0
? agentList[0]
: workflowList.length > 0
? workflowList[0]
: undefined;
// Use the first entity from the backend's original order
// This respects the backend's intended display order
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedEntity) {
@@ -140,9 +179,14 @@ export default function App() {
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
setEntityError(
error instanceof Error ? error.message : "Failed to load data"
);
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
// Check if this is an auth error
if (errorMessage === "UNAUTHORIZED") {
setAuthRequired(true);
}
setEntityError(errorMessage);
setIsLoadingEntities(false);
}
};
@@ -150,6 +194,47 @@ export default function App() {
loadData();
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
// Handle auth token submission
const handleAuthTokenSubmit = useCallback(async () => {
if (!authToken.trim()) return;
setIsTestingToken(true);
setAuthError("");
try {
// Set token in API client (stores in localStorage)
apiClient.setAuthToken(authToken.trim());
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
await apiClient.getEntities();
// If successful, reload to initialize with new token
window.location.reload();
} catch (error) {
// Token is invalid - clear it and show error
apiClient.clearAuthToken();
setIsTestingToken(false);
const errorMsg = error instanceof Error ? error.message : "Unknown error";
if (errorMsg === "UNAUTHORIZED") {
setAuthError("Invalid token. Please check and try again.");
} else {
setAuthError(`Failed to connect: ${errorMsg}`);
}
}
}, [authToken]);
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
useEffect(() => {
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
// Workflows don't work with OpenAI proxy - switch to first available agent
const firstAgent = agents[0];
if (firstAgent) {
selectEntity(firstAgent);
}
}
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
// Handle resize drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
@@ -242,12 +327,14 @@ export default function App() {
// Show error state if loading failed
if (entityError) {
const currentBackendUrl = apiClient.getBaseUrl();
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
entities={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
@@ -260,63 +347,124 @@ export default function App() {
{/* Icon */}
<div className="flex justify-center">
<div className="rounded-full bg-muted p-4 animate-pulse">
<ServerOff className="h-12 w-12 text-muted-foreground" />
{isAuthError ? (
<Lock className="h-12 w-12 text-muted-foreground" />
) : (
<ServerOff className="h-12 w-12 text-muted-foreground" />
)}
</div>
</div>
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
Can't Connect to Backend
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
</h2>
<p className="text-muted-foreground text-base">
No worries! Just start the DevUI backend server and you'll be
good to go.
{isAuthError
? "This backend requires a bearer token to access."
: "No worries! Just start the DevUI backend server and you'll be good to go."}
</p>
</div>
{/* Command Instructions */}
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Start the backend:
</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with{" "}
<code className="text-xs">serve(entities=[agent])</code>
</p>
{/* Auth Input or Command Instructions */}
{isAuthError ? (
<div className="space-y-4">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Enter Authentication Token
</p>
<Input
type="password"
placeholder="Paste token from server logs"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isTestingToken) {
handleAuthTokenSubmit();
}
}}
disabled={isTestingToken}
className="font-mono text-sm"
/>
<Button
onClick={handleAuthTokenSubmit}
disabled={!authToken.trim() || isTestingToken}
className="w-full"
>
{isTestingToken ? "Verifying..." : "Connect"}
</Button>
{/* Error message */}
{authError && (
<p className="text-sm text-red-600 dark:text-red-400 text-center">
{authError}
</p>
)}
</div>
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Where do I find the token?
</summary>
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
<p className="text-xs text-muted-foreground">
Look for this in your DevUI server startup logs:
</p>
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
🔑 DEV TOKEN (localhost only, shown once):
<br />
&nbsp;&nbsp; abc123xyz...
</code>
</div>
</details>
</div>
) : (
<>
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Start the backend:
</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with{" "}
<code className="text-xs">serve(entities=[agent])</code>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default:{" "}
<span className="font-mono">{currentBackendUrl}</span>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default:{" "}
<span className="font-mono">{currentBackendUrl}</span>
</p>
</div>
{/* Error Details (Collapsible) */}
{entityError && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{entityError}
</p>
</details>
{/* Error Details (Collapsible) */}
{entityError && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{entityError}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</div>
</div>
@@ -331,6 +479,7 @@ export default function App() {
<AppHeader
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onBrowseGallery={() => setShowGallery(true)}
@@ -377,7 +526,7 @@ export default function App() {
)}
</div>
{showDebugPanel ? (
{uiMode === "developer" && showDebugPanel ? (
<>
{/* Resize Handle */}
<div
@@ -400,31 +549,68 @@ export default function App() {
{/* Right Panel - Debug */}
<div
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: `${debugPanelWidth}px` }}
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
{debugPanelMinimized ? (
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
<div
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => setDebugPanelMinimized(false)}
title="Expand debug panel"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
Deployment Guide for {selectedAgent?.name || "Agent"}
</span>
</Button>
</div>
{/* Expand button at top (visual affordance) */}
<div className="h-8 w-8 flex items-center justify-center">
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
</div>
{/* Text and count centered in middle */}
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
<div
className="text-xs text-muted-foreground select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)'
}}
>
Debug Panel
</div>
{debugEvents.length > 0 && (
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}>
{debugEvents.length}
</div>
)}
</div>
</div>
) : (
<>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onMinimize={() => setDebugPanelMinimized(true)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
{azureDeploymentEnabled && selectedAgent?.deployment_supported
? "Deploy to Azure"
: "Deployment Guide"}
</span>
</Button>
</div>
</>
)}
</div>
</>
) : (
) : uiMode === "developer" ? (
/* Button to reopen when closed */
<div className="flex-shrink-0">
<Button
@@ -437,7 +623,7 @@ export default function App() {
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
)}
) : null}
</>
)}
</div>
@@ -450,6 +636,7 @@ export default function App() {
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
entity={selectedAgent}
/>
{/* Toast Notification */}
@@ -460,6 +647,9 @@ export default function App() {
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
{/* Toast Container for reload and other notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}
@@ -36,6 +36,7 @@ import {
X,
Copy,
CheckCheck,
RefreshCw,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -161,7 +162,12 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{new Date().toLocaleTimeString()}</span>
<span>
{item.created_at
? new Date(item.created_at * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
}
</span>
{!isUser && item.usage && (
<>
<span></span>
@@ -207,8 +213,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
const oaiMode = useDevUIStore((state) => state.oaiMode);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
@@ -227,6 +235,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
code?: string;
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -604,10 +618,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation]);
setChatItems([]);
setIsStreaming(false);
} catch {
setConversationError(null); // Clear any previous errors
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch (error) {
setAvailableConversations([]);
setChatItems([]);
setIsStreaming(false);
// Extract error details for display
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
} finally {
setLoadingConversations(false);
}
@@ -856,11 +881,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
setConversationError(null); // Clear any previous errors
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedTextRef.current = "";
} catch {
// Failed to create conversation
// Update localStorage cache with new conversation
const cachedKey = `devui_convs_${selectedAgent.id}`;
const updated = [newConversation, ...availableConversations];
localStorage.setItem(cachedKey, JSON.stringify(updated));
} catch (error) {
// Failed to create conversation - show error to user
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
}
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
@@ -915,6 +951,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle entity reload (hot reload)
const handleReloadEntity = useCallback(async () => {
if (isReloading || !selectedAgent) return;
setIsReloading(true);
const addToast = useDevUIStore.getState().addToast;
const updateAgent = useDevUIStore.getState().updateAgent;
try {
// Call backend reload endpoint
await apiClient.reloadEntity(selectedAgent.id);
// Fetch updated entity info
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
// Update store with fresh metadata
updateAgent(updatedAgent);
// Show success toast
addToast({
message: `${selectedAgent.name} has been reloaded successfully`,
type: "success",
});
} catch (error) {
// Show error toast
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
addToast({
message: `Failed to reload: ${errorMessage}`,
type: "error",
duration: 6000,
});
} finally {
setIsReloading(false);
}
}, [isReloading, selectedAgent]);
// Handle conversation selection
const handleConversationSelect = useCallback(
async (conversationId: string) => {
@@ -1002,6 +1074,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approval = pendingApprovals.find((a) => a.request_id === request_id);
if (!approval) return;
// Add user's decision as a visible message in the chat
const messageTimestamp = Math.floor(Date.now() / 1000);
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
id: `user-approval-${Date.now()}`,
type: "message",
role: "user",
content: [
{
type: "function_approval_request",
request_id: request_id,
status: approved ? "approved" : "rejected",
function_call: approval.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "completed",
created_at: messageTimestamp,
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, userDecisionMessage]);
// Create approval response in OpenAI-compatible format
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
{
@@ -1019,13 +1112,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
];
// Send approval response through the conversation
// We'll call handleSendMessage directly when invoked (it's defined below)
const request: RunAgentRequest = {
input: approvalInput,
conversation_id: currentConversation?.id,
};
// Remove from pending immediately (will be confirmed by backend event)
// Remove from pending immediately
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
@@ -1039,6 +1131,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Check if this is a function approval response (internal, don't show in chat)
const isApprovalResponse = request.input.some(
(inputItem) =>
inputItem.type === "message" &&
Array.isArray(inputItem.content) &&
inputItem.content.some((c) => c.type === "function_approval_response")
);
// Extract content from OpenAI format to create ConversationMessage
const messageContent: import("@/types/openai").MessageContent[] = [];
@@ -1069,16 +1169,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
}
// Add user message to UI state (OpenAI ConversationMessage)
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
};
// Capture timestamp once for both user and assistant messages
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
// Only add user message to UI if it's not an approval response (internal messages)
if (!isApprovalResponse && messageContent.length > 0) {
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
}
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
@@ -1088,6 +1195,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
role: "assistant",
content: [], // Will be filled during streaming
status: "in_progress",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
@@ -1102,8 +1210,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
setCurrentConversation(conversationToUse);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
} catch {
// Failed to create conversation
setConversationError(null); // Clear any previous errors
} catch (error) {
// Failed to create conversation - show error and stop execution
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
setIsSubmitting(false);
setIsStreaming(false);
return; // Stop execution - can't send message without conversation
}
}
@@ -1145,16 +1262,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
continue; // Continue processing other events
}
// Handle response.failed event
// Handle response.failed event (OpenAI standard)
if (openAIEvent.type === "response.failed") {
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
const error = failedEvent.response?.error;
const errorMessage = error
? typeof error === "object" && "message" in error
? (error as any).message
: JSON.stringify(error)
: "Request failed";
// Format error message with details
let errorMessage = "Request failed";
if (error) {
if (typeof error === "object" && "message" in error) {
errorMessage = error.message as string;
if ("code" in error && error.code) {
errorMessage += ` (Code: ${error.code})`;
}
} else if (typeof error === "string") {
errorMessage = error;
}
}
// Update assistant message with error
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1171,14 +1297,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: item
));
setIsStreaming(false);
return;
return; // Exit stream processing on failure
}
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals
// Add to pending approvals (for popup)
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -1186,17 +1312,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
function_call: approvalEvent.function_call,
},
]);
continue; // Don't add approval requests to chat UI
// Also add to chat UI to show function call progress
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
return {
...item,
content: [
...item.content,
{
type: "function_approval_request",
request_id: approvalEvent.request_id,
status: "pending",
function_call: approvalEvent.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "in_progress" as const,
};
}
return item;
}));
continue;
}
// Handle function approval response events
if (openAIEvent.type === "response.function_approval.responded") {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Handle function result events (after function execution)
if (openAIEvent.type === "response.function_result.complete") {
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
// Remove from pending approvals
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
// Add function result as a separate conversation item for clear visibility
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
id: `result-${Date.now()}`,
type: "function_call_output",
call_id: resultEvent.call_id,
output: resultEvent.output,
status: resultEvent.status === "completed" ? "completed" : "incomplete",
created_at: Math.floor(Date.now() / 1000),
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, functionResultItem]);
continue;
}
@@ -1227,6 +1382,57 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
return; // Exit stream processing early on error
}
// Handle output item added events (images, files, data)
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
// Add output items to assistant message content
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((chatItem) => {
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
const existingContent = chatItem.content;
let newContent: import("@/types/openai").MessageContent | null = null;
// Map output items to message content
if (item.type === "output_image") {
newContent = {
type: "output_image",
image_url: item.image_url,
alt_text: item.alt_text,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputImage;
} else if (item.type === "output_file") {
newContent = {
type: "output_file",
filename: item.filename,
file_url: item.file_url,
file_data: item.file_data,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputFile;
} else if (item.type === "output_data") {
newContent = {
type: "output_data",
data: item.data,
mime_type: item.mime_type,
description: item.description,
} as import("@/types/openai").MessageOutputData;
}
// If we created new content, append it
if (newContent) {
return {
...chatItem,
content: [...existingContent, newContent],
status: "in_progress" as const,
};
}
}
return chatItem;
}));
continue; // Continue to next event
}
// Handle text delta events for chat
if (
openAIEvent.type === "response.output_text.delta" &&
@@ -1236,21 +1442,26 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
}
// Handle completion/error by detecting when streaming stops
@@ -1435,19 +1646,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
Chat with {selectedAgent.name || selectedAgent.id}
{oaiMode.enabled
? `Chat with ${oaiMode.model}`
: `Chat with ${selectedAgent.name || selectedAgent.id}`
}
</span>
</div>
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
{!oaiMode.enabled && uiMode === "developer" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReloadEntity}
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
className="h-6 w-6 p-0 flex-shrink-0"
title={
selectedAgent.metadata?.source === "in_memory"
? "In-memory entities cannot be reloaded"
: isReloading
? "Reloading..."
: "Reload entity code (hot reload)"
}
>
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
</Button>
</>
)}
</div>
{/* Conversation Controls */}
@@ -1539,13 +1773,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
{selectedAgent.description && (
{oaiMode.enabled ? (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
Using OpenAI model directly. Local agent tools and instructions are not applied.
</p>
) : (
selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)
)}
</div>
{/* Error Banner */}
{conversationError && (
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-destructive">
Failed to Create Conversation
</div>
<div className="text-xs text-destructive/90 mt-1 break-words">
{conversationError.message}
</div>
{conversationError.code && (
<div className="text-xs text-destructive/70 mt-1">
Error Code: {conversationError.code}
</div>
)}
</div>
<button
onClick={() => setConversationError(null)}
className="text-destructive hover:text-destructive/80 flex-shrink-0"
title="Dismiss error"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
@@ -11,6 +11,9 @@ import {
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
@@ -37,12 +40,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
);
}
// Image content renderer
// Image content renderer (handles both input and output images)
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image") return null;
if (content.type !== "input_image" && content.type !== "output_image") return null;
const imageUrl = content.image_url;
@@ -77,9 +80,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
);
}
// File content renderer
// File content renderer (handles both input and output files)
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file") return null;
if (content.type !== "input_file" && content.type !== "output_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
@@ -156,6 +159,129 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
);
}
// Data content renderer (for generic structured data outputs)
function DataContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "output_data") return null;
const data = content.data;
const mimeType = content.mime_type;
const description = content.description;
// Try to parse as JSON for pretty printing
let displayData = data;
try {
const parsed = JSON.parse(data);
displayData = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, display as-is
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{description || "Data Output"}
</span>
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
{isExpanded && (
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
{displayData}
</pre>
)}
</div>
);
}
// Function approval request renderer
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "function_approval_request") return null;
const [isExpanded, setIsExpanded] = useState(false);
const { status, function_call } = content;
// Status styling
const statusConfig = {
pending: {
icon: Clock,
color: "amber",
label: "Awaiting Approval",
bgClass: "bg-amber-50 dark:bg-amber-950/20",
borderClass: "border-amber-200 dark:border-amber-800",
iconClass: "text-amber-600 dark:text-amber-400",
textClass: "text-amber-800 dark:text-amber-300",
},
approved: {
icon: Check,
color: "green",
label: "Approved",
bgClass: "bg-green-50 dark:bg-green-950/20",
borderClass: "border-green-200 dark:border-green-800",
iconClass: "text-green-600 dark:text-green-400",
textClass: "text-green-800 dark:text-green-300",
},
rejected: {
icon: X,
color: "red",
label: "Rejected",
bgClass: "bg-red-50 dark:bg-red-950/20",
borderClass: "border-red-200 dark:border-red-800",
iconClass: "text-red-600 dark:text-red-400",
textClass: "text-red-800 dark:text-red-300",
},
};
const config = statusConfig[status];
const StatusIcon = config.icon;
let parsedArgs;
try {
parsedArgs = typeof function_call.arguments === "string"
? JSON.parse(function_call.arguments)
: function_call.arguments;
} catch {
parsedArgs = function_call.arguments;
}
return (
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
<span className={`text-sm font-medium ${config.textClass}`}>
{config.label}: {function_call.name}
</span>
{isExpanded ? (
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
) : (
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className={`${config.textClass} mb-1`}>Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
@@ -164,9 +290,15 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
case "output_text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
case "output_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
case "output_file":
return <FileContentRenderer content={content} className={className} />;
case "output_data":
return <DataContentRenderer content={content} className={className} />;
case "function_approval_request":
return <FunctionApprovalRequestRenderer content={content} className={className} />;
default:
return null;
}
@@ -0,0 +1,523 @@
/**
* ExecutionTimeline - Vertical timeline showing workflow executor runs
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
*/
import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExecutorState } from "./executor-node";
interface ExecutorRun {
executorId: string;
executorName: string;
itemId: string; // Unique ID for this specific run
state: ExecutorState;
output: string;
error?: string;
timestamp: number;
runNumber: number; // For multiple runs of same executor
}
interface ExecutionTimelineProps {
events: ExtendedResponseStreamEvent[];
itemOutputs: Record<string, string>;
currentExecutorId: string | null;
isStreaming: boolean;
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
}
function getStateIcon(state: ExecutorState) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
case "cancelled":
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
default:
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
}
}
function getStateBadgeClass(state: ExecutorState) {
switch (state) {
case "running":
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
case "completed":
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
case "failed":
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
case "cancelled":
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
default:
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
}
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
const canExpand = hasOutput || run.error;
const outputRef = useRef<HTMLPreElement>(null);
// Auto-scroll output to bottom when content changes (during streaming)
useEffect(() => {
if (isExpanded && run.state === "running" && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [run.output, isExpanded, run.state]);
return (
<div
className={`border rounded-lg transition-all ${
isSelected
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header - Always Visible */}
<div
className="p-3 cursor-pointer"
onClick={() => {
onClick();
if (canExpand) onToggle();
}}
>
<div className="flex items-center gap-2 mb-1">
{canExpand && (
<div className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</div>
)}
{getStateIcon(run.state)}
<span className="font-medium text-sm truncate flex-1">
{run.executorName}
</span>
{run.runNumber > 1 && (
<Badge variant="outline" className="text-xs">
Run #{run.runNumber}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
<span className="font-mono">{timestamp}</span>
<Badge
variant="outline"
className={`text-xs border ${getStateBadgeClass(run.state)}`}
>
{run.state}
</Badge>
</div>
</div>
{/* Expandable Content */}
{isExpanded && canExpand && (
<div className="border-t px-3 py-2 bg-muted/30">
{run.error ? (
<div className="space-y-1">
<div className="text-xs font-medium text-red-600 dark:text-red-400">
Error:
</div>
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
{run.error}
</pre>
</div>
) : (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Output:
</div>
<pre
ref={outputRef}
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
>
{run.output}
</pre>
</div>
)}
</div>
)}
</div>
);
}
export function ExecutionTimeline({
events,
itemOutputs,
currentExecutorId,
isStreaming,
onExecutorClick,
selectedExecutorId,
workflowResult,
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
// This polling approach ensures the UI updates during streaming. Could be optimized by:
// 1. Converting itemOutputs to state (increases re-renders)
// 2. Using requestAnimationFrame instead of setInterval
// 3. Having parent component trigger updates via callback
useEffect(() => {
if (isStreaming) {
const interval = setInterval(() => {
setUpdateTrigger((prev) => prev + 1);
}, 100); // Update 10 times per second during streaming
return () => clearInterval(interval);
}
}, [isStreaming]);
// Process events to extract executor runs - memoized to prevent recalculation
const { executorRuns, executorRunCount } = useMemo(() => {
const runs: ExecutorRun[] = [];
const runCount = new Map<string, number>();
events.forEach((event) => {
// Extract UI timestamp (captured when event arrived, won't change on re-render)
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? event._uiTimestamp * 1000
: Date.now();
// Handle new standard OpenAI events
if (event.type === "response.output_item.added") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const executorId = item.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: executorId,
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message items from Magentic agents
const executorId = item.metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: executorId,
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const itemId = item.id;
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state =
item.status === "completed"
? "completed"
: item.status === "failed"
? "failed"
: "completed";
// Use item-specific output, not executor-wide output
existingRun.output = itemOutputs[itemId] || "";
if (item.status === "failed" && item.error) {
existingRun.error = item.error;
}
}
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message completion from Magentic agents
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
}
}
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
const executorId = data.executor_id;
if (!executorId) return;
const eventType = data.event_type;
if (eventType === "ExecutorInvokedEvent") {
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
// Create synthetic item ID for fallback format (no real item.id from backend)
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
runs.push({
executorId,
executorName: executorId,
itemId: syntheticItemId,
state: "running",
output: itemOutputs[syntheticItemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (eventType === "ExecutorCompletedEvent") {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "completed";
existingRun.output = itemOutputs[existingRun.itemId] || "";
}
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "failed";
existingRun.error =
typeof data.data === "string" ? data.data : "Execution failed";
}
}
}
});
// Update outputs for running executors using item-specific outputs
// This ensures each run gets its own output, even for multiple runs of the same executor
runs.forEach((run) => {
if (run.state === "running" && itemOutputs[run.itemId]) {
run.output = itemOutputs[run.itemId];
}
});
return { executorRuns: runs, executorRunCount: runCount };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, itemOutputs, updateTrigger]);
// Auto-expand running executors
useEffect(() => {
if (currentExecutorId) {
setExpandedRuns((prev) => {
const next = new Set(prev);
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
return next;
});
}
}, [currentExecutorId, executorRunCount]);
// Auto-scroll to newest executor when it appears or changes
useEffect(() => {
if (executorRuns.length > 0 && isStreaming) {
const latestRun = executorRuns[executorRuns.length - 1];
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
// Only scroll if this is a new run we haven't scrolled to yet
if (latestRunKey !== lastScrolledRunRef.current) {
lastScrolledRunRef.current = latestRunKey;
// Scroll to the end of the timeline
if (timelineEndRef.current) {
timelineEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}
}
}, [executorRuns, isStreaming]);
// Auto-scroll to show workflow result when it appears (after streaming completes)
useEffect(() => {
if (workflowResult && !isStreaming && timelineEndRef.current) {
// Small delay to ensure the result card is rendered before scrolling
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [workflowResult, isStreaming]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
const content = run.error || run.output || "(no output)";
return `${header}\n${content}\n`;
})
.join("\n");
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="h-full flex flex-col border-l bg-muted/30">
{/* Header */}
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Execution Timeline</span>
<Badge variant="outline" className="text-xs">
{executorRuns.length}
</Badge>
{isStreaming && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
<span>Running</span>
</div>
)}
</div>
{executorRuns.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleCopyAll}
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy All
</>
)}
</Button>
)}
</div>
{/* Timeline Content */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
<span className="font-medium text-sm">Workflow Complete</span>
</div>
</div>
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Final Output:
</div>
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
{workflowResult}
</pre>
</div>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
</div>
);
}
@@ -3,6 +3,7 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -155,34 +156,32 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{/* Small circular handles - always render both to support any edge configuration */}
<Handle
type="target"
position={targetPosition}
id="target"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
{!nodeData.isEndNode && (
<Handle
type="source"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<Handle
type="source"
position={sourcePosition}
id="source"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
<div className="p-3">
{/* Header with icon and title */}
@@ -196,18 +195,16 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
{/* Small status badge for running state */}
{isRunning && (
<div className={cn(
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
config.badgeColor
)} />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
)}
</div>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
@@ -0,0 +1,150 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -7,3 +7,5 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
@@ -0,0 +1,546 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { JSONSchemaProperty } from "@/types";
// ============================================================================
// Field Type Detection (from WorkflowInputForm)
// ============================================================================
function isShortField(fieldName: string): boolean {
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
"email",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
function getFieldColumnSpan(
fieldName: string,
schema: JSONSchemaProperty
): string {
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
const hasLongDescription =
!!schema.description && schema.description.length > 150;
if (isTextarea || hasLongDescription) {
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
}
if (
schema.type === "array" ||
(!!schema.description && schema.description.length > 80)
) {
return "xl:col-span-2";
}
return "";
}
// ============================================================================
// ChatMessage Pattern Detection (from WorkflowInputForm)
// ============================================================================
function detectChatMessagePattern(
schema: JSONSchemaProperty,
requiredFields: string[]
): boolean {
if (schema.type !== "object" || !schema.properties) return false;
const properties = schema.properties;
const optionalFields = Object.keys(properties).filter(
(name) => !requiredFields.includes(name)
);
return (
requiredFields.includes("role") &&
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
properties["role"]?.type === "string"
);
}
// ============================================================================
// Form Field Component (from WorkflowInputForm)
// ============================================================================
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
isReadOnly?: boolean; // NEW: for HIL display-only fields
}
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
const isTextarea = shouldFieldBeTextarea(name, schema);
const renderInput = () => {
// Read-only display (for HIL request context)
if (isReadOnly) {
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-muted-foreground">
{name}
</Label>
<div className="text-sm p-2 bg-muted rounded border">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
}
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (isTextarea) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={4}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "integer":
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
step={type === "integer" ? "1" : "any"}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val =
type === "integer"
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
className="font-mono text-xs"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
};
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
}
// ============================================================================
// Main Schema Form Renderer Component
// ============================================================================
export interface SchemaFormRendererProps {
schema: JSONSchemaProperty;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled?: boolean;
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
hideFields?: string[]; // NEW: Fields to completely hide
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect ChatMessage pattern
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
// Separate required and optional fields
const requiredFieldNames = allFieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allFieldNames.filter(
(name) => !requiredFields.includes(name)
);
// For ChatMessage: prioritize text/message/content
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Show minimum visible fields
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(
0,
MIN_VISIBLE_FIELDS - requiredFieldNames.length
);
const visibleOptionalFields = sortedOptionalFields.slice(
0,
visibleOptionalCount
);
const collapsedOptionalFields = sortedOptionalFields.slice(
visibleOptionalCount
);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
const updateField = (fieldName: string, value: unknown) => {
onChange({
...values,
[fieldName]: value,
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div className="border-t border-border"></div>
</div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
disabled={disabled}
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields */}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
</div>
);
}
// ============================================================================
// Export helper functions for validation
// ============================================================================
export function validateSchemaForm(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): boolean {
const requiredFields = schema.required || [];
return requiredFields.every((fieldName) => {
const value = values[fieldName];
return value !== undefined && value !== "" && value !== null;
});
}
export function filterEmptyOptionalFields(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): Record<string, unknown> {
const requiredFields = schema.required || [];
const filtered: Record<string, unknown> = {};
Object.keys(values).forEach((key) => {
const value = values[key];
// Include if: 1) required field, OR 2) has non-empty value
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filtered[key] = value;
}
});
return filtered;
}
@@ -8,6 +8,7 @@ import {
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -39,6 +40,7 @@ import {
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -59,7 +61,7 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -134,6 +136,16 @@ function ViewOptionsPanel({
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center justify-between"
@@ -192,12 +204,14 @@ interface WorkflowFlowProps {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
timelineVisible?: boolean;
}
// Animation handler component that runs inside ReactFlow context
@@ -248,16 +262,35 @@ function WorkflowAnimationHandler({
return null; // This component doesn't render anything
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
@@ -272,17 +305,22 @@ export const WorkflowFlow = memo(function WorkflowFlow({
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply bidirectional edge consolidation if enabled
const finalEdges = viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(edges)
: edges;
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
? applyDagreLayout(nodes, finalEdges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
initialEdges: finalEdges,
};
}, [workflowDump, onNodeSelect, layoutDirection]);
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -323,31 +361,38 @@ export const WorkflowFlow = memo(function WorkflowFlow({
currentEdges,
events
);
return updatedEdges;
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(updatedEdges)
: updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
setEdges((currentEdges) => {
const resetEdges = currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}))
);
}));
// Apply consolidation if enabled
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(resetEdges)
: resetEdges;
});
}
}, [events, setEdges]);
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
// Initialize nodes only when workflow structure changes (not on state updates)
// Initialize nodes and edges when workflow structure OR consolidation setting changes
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump]); // Only re-initialize when workflowDump changes
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
@@ -467,6 +512,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
@@ -0,0 +1,219 @@
/**
* Workflow Conversation Manager Component
* Handles conversation selection, creation, and deletion for workflow executions
*/
import React, { useEffect, useState, useCallback } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import { apiClient } from "@/services/api";
import { Trash2, Plus, Clock } from "lucide-react";
import type { WorkflowSession } from "@/types";
interface WorkflowSessionManagerProps {
workflowId: string;
onSessionChange?: (session: WorkflowSession | undefined) => void;
}
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
workflowId,
onSessionChange,
}) => {
// Use individual selectors to avoid creating new objects on every render
const currentSession = useDevUIStore((state) => state.currentSession);
const availableSessions = useDevUIStore((state) => state.availableSessions);
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const [creatingSession, setCreatingSession] = useState(false);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoadingSessions(true);
try {
const response = await apiClient.listWorkflowSessions(workflowId);
// If no conversations exist, auto-create one (like agent conversations)
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
type: "success",
});
} else {
// Conversations exist - set available and auto-select the first one
setAvailableSessions(response.data);
// Auto-select first conversation if no current selection
if (!currentSession) {
const firstSession = response.data[0];
setCurrentSession(firstSession);
onSessionChange?.(firstSession);
}
}
} catch (error) {
console.error("Failed to load workflow conversations:", error);
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleCreateSession = async () => {
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
addToast({
message: "Failed to create conversation",
type: "error",
});
} finally {
setCreatingSession(false);
}
};
const handleSelectSession = (session: WorkflowSession) => {
setCurrentSession(session);
onSessionChange?.(session);
};
const handleDeleteSession = async (
sessionId: string,
event: React.MouseEvent
) => {
event.stopPropagation(); // Prevent session selection when clicking delete
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
return;
}
setDeletingSession(sessionId);
try {
await apiClient.deleteWorkflowSession(workflowId, sessionId);
removeSession(sessionId);
addToast({
message: "Conversation deleted",
type: "success",
});
} catch (error) {
console.error("Failed to delete conversation:", error);
addToast({
message: "Failed to delete conversation",
type: "error",
});
} finally {
setDeletingSession(null);
}
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
if (loadingSessions) {
return (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
</div>
);
}
return (
<div className="workflow-session-manager space-y-3">
{/* Header with Create Button */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Conversations
</h3>
<button
onClick={handleCreateSession}
disabled={creatingSession}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Create new conversation"
>
<Plus className="h-4 w-4" />
New Conversation
</button>
</div>
{/* Conversation List */}
{availableSessions.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
Loading conversations...
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableSessions.map((session) => (
<div
key={session.conversation_id}
onClick={() => handleSelectSession(session)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
${
currentSession?.conversation_id === session.conversation_id
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{session.metadata.name || "Unnamed Conversation"}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(session.created_at)}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
disabled={deletingSession === session.conversation_id}
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
title="Delete conversation"
>
{deletingSession === session.conversation_id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -4,14 +4,17 @@
*/
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EntitySelector } from "./entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings } from "lucide-react";
import { Settings, Zap } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
import { useDevUIStore } from "@/stores";
interface AppHeaderProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -22,12 +25,15 @@ interface AppHeaderProps {
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="flex items-center gap-2 font-semibold">
@@ -58,15 +64,29 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
<Zap className="h-3 w-3" />
OpenAI: {oaiMode.model}
</Badge>
)}
</div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
{/* Show entity selector only when NOT in OAI mode */}
{!oaiMode.enabled && (
<EntitySelector
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
)}
<div className="flex-1"></div>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
@@ -20,7 +20,6 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -95,7 +94,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onClose?: () => void;
onMinimize?: () => void;
}
// Helper: Extract function result from DevUI custom event
@@ -116,39 +115,6 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
return null;
}
// Helper to get a stable timestamp for an event
// Uses event's own timestamp fields if available
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
if ('timestamp' in event && typeof event.timestamp === 'string') {
return new Date(event.timestamp).toLocaleTimeString();
}
// Priority 2: Check for nested data.timestamp (workflow/trace events)
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
const dataTimestamp = (event.data as any).timestamp;
if (typeof dataTimestamp === 'string') {
return new Date(dataTimestamp).toLocaleTimeString();
}
}
// Priority 3: Check for created_at in response object (lifecycle events)
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
const createdAt = (event.response as any).created_at;
if (typeof createdAt === 'number') {
return new Date(createdAt * 1000).toLocaleTimeString();
}
}
// Fallback: use sequence number as label (better than showing same time for all)
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
return `#${event.sequence_number}`;
}
// Last resort: hide timestamp by returning empty string
return '';
}
// Helper function to accumulate OpenAI events into meaningful units
function processEventsForDisplay(
events: ExtendedResponseStreamEvent[]
@@ -170,8 +136,8 @@ function processEventsForDisplay(
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
event.type === "response.trace.completed" ||
event.type === "response.trace.completed"
) {
continue;
}
@@ -212,9 +178,9 @@ function processEventsForDisplay(
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.workflow_event.completed" ||
event.type === "response.trace.completed" ||
event.type === "response.trace.completed" ||
isFunctionResult
) {
// Flush any accumulated text before showing these events
@@ -228,8 +194,8 @@ function processEventsForDisplay(
// Extract function names from trace events
if (
(event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete") &&
(event.type === "response.trace.completed" ||
event.type === "response.trace.completed") &&
"data" in event
) {
const traceData = event.data as TraceEventData;
@@ -483,15 +449,14 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
return "Output item added";
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return `Executor: ${data.executor_id || "unknown"}`;
}
return "Workflow event";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return `Trace: ${data.operation_name || "unknown"}`;
@@ -536,10 +501,9 @@ function getEventIcon(type: string) {
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return Activity;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return Search;
case "response.completed":
return CheckCircle2;
@@ -564,10 +528,9 @@ function getEventColor(type: string) {
return "text-green-600 dark:text-green-400";
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return "text-purple-600 dark:text-purple-400";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return "text-orange-600 dark:text-orange-400";
case "response.completed":
return "text-green-600 dark:text-green-400";
@@ -582,9 +545,15 @@ function getEventColor(type: string) {
function EventItem({ event }: EventItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const Icon = getEventIcon(event.type);
const colorClass = getEventColor(event.type);
const timestamp = getEventTimestamp(event);
const eventType = event.type || "unknown";
const Icon = getEventIcon(eventType);
const colorClass = getEventColor(eventType);
// Use stored UI timestamp if available, otherwise compute from event data
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
const summary = getEventSummary(event);
// Determine if this event has expandable content
@@ -595,13 +564,13 @@ function EventItem({ event }: EventItemProps) {
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
(event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace_event.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.output_text.delta" &&
@@ -620,7 +589,7 @@ function EventItem({ event }: EventItemProps) {
<Icon className={`h-3 w-3 ${colorClass}`} />
<span className="font-mono">{timestamp}</span>
<Badge variant="outline" className="text-xs py-0">
{event.type.replace("response.", "")}
{event.type ? event.type.replace("response.", "") : "unknown"}
</Badge>
</div>
@@ -859,7 +828,7 @@ function EventExpandedContent({
break;
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return (
@@ -915,8 +884,7 @@ function EventExpandedContent({
}
break;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return (
@@ -1193,8 +1161,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// ONLY show actual trace events - handle both event type formats
const traceEvents = events.filter(
(e) =>
e.type === "response.trace_event.complete" ||
e.type === "response.trace.complete"
e.type === "response.trace.completed" ||
e.type === "response.trace.completed"
);
// Add separators between message rounds
@@ -1253,8 +1221,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const [isExpanded, setIsExpanded] = useState(false);
if (
(event.type !== "response.trace_event.complete" &&
event.type !== "response.trace.complete") ||
(event.type !== "response.trace.completed" &&
event.type !== "response.trace.completed") ||
!("data" in event)
) {
return (
@@ -1266,14 +1234,19 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const data = event.data as TraceEventData;
// Use actual trace timestamp if available, fallback to current time
let timestamp = new Date().toLocaleTimeString();
if (data.end_time) {
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
let timestamp: string;
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
// Use stored UI timestamp from when event was received
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
} else if (data.end_time) {
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
} else if (data.start_time) {
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
} else if (data.timestamp) {
timestamp = new Date(data.timestamp).toLocaleTimeString();
} else {
timestamp = new Date().toLocaleTimeString();
}
const operationName = data.operation_name || "Unknown Operation";
@@ -1520,7 +1493,10 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const timestamp = getEventTimestamp(event);
// Use stored UI timestamp if available, otherwise compute from current time
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
@@ -1621,7 +1597,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
export function DebugPanel({
events,
isStreaming = false,
onClose,
onMinimize,
}: DebugPanelProps) {
return (
<div className="flex-1 border-l flex flex-col min-h-0">
@@ -1638,15 +1614,15 @@ export function DebugPanel({
Tools
</TabsTrigger>
</TabsList>
{onClose && (
{onMinimize && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
onClick={onMinimize}
className="h-8 w-8 p-0 flex-shrink-0"
title="Hide debug panel"
title="Minimize debug panel"
>
<PanelRightClose className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
@@ -20,12 +20,18 @@ import {
Copy,
CheckCircle2,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react";
import { useDevUIStore } from "@/stores";
import { apiClient } from "@/services/api";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
entity?: AgentInfo | WorkflowInfo;
}
type Tab = "docker" | "azure";
@@ -34,10 +40,108 @@ export function DeploymentModal({
open,
onClose,
agentName = "Agent",
entity,
}: DeploymentModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("docker");
// Get the Azure deployment feature flag from store
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
// Check if deployment is truly supported (both feature flag and backend support)
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
const [activeTab, setActiveTab] = useState<Tab>(
deploymentSupported ? "azure" : "docker"
);
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
// Deployment state from Zustand
const isDeploying = useDevUIStore((state) => state.isDeploying);
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
const startDeployment = useDevUIStore((state) => state.startDeployment);
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
// Generate Azure-compliant default app name from entity name
const generateDefaultAppName = (entityName: string) => {
// Convert to lowercase, replace spaces and underscores with hyphens
// Remove any non-alphanumeric characters except hyphens
// Ensure it starts with a letter and is under 32 chars
const cleaned = entityName
.toLowerCase()
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
.replace(/--+/g, '-') // Replace multiple hyphens with single
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
.replace(/-$/, ''); // Remove trailing hyphen
// Ensure it starts with a letter, add 'app-' prefix if needed
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
// Truncate to 31 chars max (32 limit)
return withPrefix.substring(0, 31);
};
// Form state for deployment with smart defaults
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
const [appName, setAppName] = useState(defaultAppName);
const [region, setRegion] = useState("eastus");
const [appNameError, setAppNameError] = useState<string | null>(null);
// Update app name when entity changes or modal opens
useEffect(() => {
if (entity) {
const newDefaultName = generateDefaultAppName(entity.id);
setAppName(newDefaultName);
// Validate the default name
const error = validateAppName(newDefaultName);
setAppNameError(error);
}
}, [entity?.id]); // Only re-run when entity ID changes
// Auto-scroll deployment logs to bottom when new logs are added
useEffect(() => {
if (logsContainerRef.current && deploymentLogs.length > 0) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [deploymentLogs]);
// Validate Azure Container App name
const validateAppName = (name: string): string | null => {
if (!name) return null; // Don't show error for empty field
// Check length
if (name.length >= 32) {
return "App name must be less than 32 characters";
}
// Check for valid characters (lowercase alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(name)) {
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
}
// Must start with a letter
if (!/^[a-z]/.test(name)) {
return "App name must start with a lowercase letter";
}
// Must end with alphanumeric
if (!/[a-z0-9]$/.test(name)) {
return "App name must end with a letter or number";
}
// Cannot have double hyphens
if (name.includes("--")) {
return "App name cannot contain consecutive hyphens (--)";
}
return null;
};
// Cleanup timeout on unmount
useEffect(() => {
@@ -48,6 +152,48 @@ export function DeploymentModal({
};
}, []);
const handleDeploy = async () => {
if (!entity?.id || !resourceGroup || !appName) return;
// Trim whitespace from inputs
const trimmedResourceGroup = resourceGroup.trim();
const trimmedAppName = appName.trim();
// Validate trimmed app name before deployment
const nameError = validateAppName(trimmedAppName);
if (nameError) {
setAppNameError(nameError);
return;
}
try {
startDeployment();
for await (const event of apiClient.streamDeployment({
entity_id: entity.id,
resource_group: trimmedResourceGroup,
app_name: trimmedAppName,
region,
ui_mode: "user",
})) {
addDeploymentLog(event.message);
if (event.type === "deploy.completed" && event.url && event.auth_token) {
setDeploymentResult({
url: event.url,
authToken: event.auth_token,
});
} else if (event.type === "deploy.failed") {
// Stop deploying but keep logs visible
stopDeployment();
}
}
} catch (error) {
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
stopDeployment();
}
};
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
@@ -64,8 +210,7 @@ export function DeploymentModal({
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy template:", err);
// Reset state on error
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
};
@@ -149,20 +294,22 @@ openai>=1.0.0
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{deploymentSupported && (
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
</div>
{/* Tab Content */}
@@ -360,34 +507,230 @@ openai>=1.0.0
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
{deploymentSupported
? "One-click deployment to Azure with automatic containerization and authentication."
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
</p>
</div>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
{/* Prerequisites Notice */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
Prerequisites for Azure Deployment
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
<li>Azure subscription with the following providers registered:
<ul className="ml-4 mt-1 space-y-0.5">
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
</ul>
</li>
</ul>
<details className="mt-2">
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
How to register providers?
</summary>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
<p className="mb-1">Run these commands once per subscription:</p>
<code className="block font-mono">
az provider register -n Microsoft.App --wait<br/>
az provider register -n Microsoft.ContainerRegistry --wait<br/>
az provider register -n Microsoft.OperationalInsights --wait
</code>
</div>
</details>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
{/* Functional Deployment Form (only if supported) */}
{deploymentSupported && entity && !lastDeployment && (
<div className="border rounded-lg p-4 space-y-4">
{!isDeploying ? (
<>
<div className="space-y-3">
<div>
<label className="text-sm font-medium">Resource Group</label>
<input
type="text"
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
placeholder="my-test-rg"
value={resourceGroup}
onChange={(e) => setResourceGroup(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">App Name</label>
<input
type="text"
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${
appNameError ? "border-red-500" : ""
}`}
placeholder="my-agent-app"
value={appName}
onChange={(e) => {
const newName = e.target.value;
setAppName(newName);
// Validate on change to provide immediate feedback
// Trim for validation to match what will be sent
const error = validateAppName(newName.trim());
setAppNameError(error);
}}
/>
{appNameError && (
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Region</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
value={region}
onChange={(e) => setRegion(e.target.value)}
>
<option value="eastus">East US</option>
<option value="westus">West US</option>
<option value="westeurope">West Europe</option>
<option value="eastasia">East Asia</option>
</select>
</div>
</div>
<Button
onClick={handleDeploy}
disabled={!resourceGroup || !appName || !!appNameError}
className="w-full"
>
<Rocket className="h-4 w-4 mr-2" />
Deploy to Azure
</Button>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Deploying...
</div>
<div
ref={logsContainerRef}
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
>
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
</div>
)}
{/* Show logs after deployment stops (success or failure) */}
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4" />
Deployment Failed
</div>
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Try Again
</Button>
</div>
)}
</div>
)}
{/* Success Screen */}
{lastDeployment && (
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<h4 className="font-semibold text-green-900 dark:text-green-100">
Deployment Successful!
</h4>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Deployment URL
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
{lastDeployment.url}
</code>
<Button
size="sm"
variant="outline"
onClick={() => window.open(lastDeployment.url, "_blank")}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Auth Token (save this - shown only once)
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
{lastDeployment.authToken}
</code>
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Deploy Another
</Button>
</div>
)}
{/* Deployment Not Supported Warning */}
{!deploymentSupported && entity?.deployment_reason && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<strong>Deployment not available:</strong> {entity.deployment_reason}
</div>
</div>
</div>
)}
{/* CLI Instructions (only show when deployment not supported) */}
{!deploymentSupported && (
<>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
<div className="space-y-3">
{/* Step 1 */}
@@ -508,6 +851,8 @@ az acr build --registry myregistry \\
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
@@ -20,6 +20,7 @@ import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -33,6 +34,7 @@ const getTypeIcon = (type: "agent" | "workflow") => {
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
@@ -40,9 +42,8 @@ export function EntitySelector({
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
const allItems = [...agents, ...workflows].sort(
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
@@ -82,80 +83,125 @@ export function EntitySelector({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={agent.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show items in backend order but with type grouping for clarity */}
{(() => {
// Group items by type while preserving order within each group
const workflowItems = allItems.filter(item => item.type === "workflow");
const agentItems = allItems.filter(item => item.type === "agent");
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={workflow.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
// Determine which type appears first in backend order
const firstItemType = allItems[0]?.type;
return (
<>
{/* Show workflows first if they appear first, otherwise agents */}
{firstItemType === "workflow" && workflowItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Separator if both types exist */}
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
{/* Agents section */}
{agentItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agentItems.length})
</DropdownMenuLabel>
{agentItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show workflows last if agents appear first */}
{firstItemType === "agent" && workflowItems.length > 0 && (
<>
{agentItems.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</>
);
})()}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
@@ -13,7 +13,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExternalLink, RotateCcw } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
import { useDevUIStore } from "@/stores";
interface SettingsModalProps {
open: boolean;
@@ -21,10 +23,26 @@ interface SettingsModalProps {
onBackendUrlChange?: (url: string) => void;
}
type Tab = "about" | "settings";
type Tab = "general" | "proxy" | "about";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Preset OpenAI models for quick selection
const PRESET_MODELS = [
"gpt-4.1",
"gpt-4.1-mini",
"o1",
"o1-mini",
"o3-mini",
] as const;
export function SettingsModal({
open,
onOpenChange,
onBackendUrlChange,
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, and auth status from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -33,6 +51,10 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
});
const [tempUrl, setTempUrl] = useState(backendUrl);
// Auth token state
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
const [newAuthToken, setNewAuthToken] = useState("");
const handleSave = () => {
// Validate URL format
try {
@@ -59,30 +81,63 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
window.location.reload();
};
const handleAuthTokenSave = () => {
if (!newAuthToken.trim()) return;
localStorage.setItem("devui_auth_token", newAuthToken.trim());
setAuthTokenStored(true);
setNewAuthToken("");
// Reload to apply the auth token
window.location.reload();
};
const handleClearAuthToken = () => {
localStorage.removeItem("devui_auth_token");
setAuthTokenStored(false);
setNewAuthToken("");
// Reload to clear auth state
window.location.reload();
};
const isModified = tempUrl !== backendUrl;
const isDefault = !localStorage.getItem("devui_backend_url");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[600px] max-w-[90vw]">
<DialogHeader className="p-6 pb-2">
<DialogContent className="w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]">
<DialogHeader className="p-6 pb-2 flex-shrink-0">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6">
<div className="flex border-b px-6 flex-shrink-0">
<button
onClick={() => setActiveTab("settings")}
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "settings"
activeTab === "general"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Settings
{activeTab === "settings" && (
General
{activeTab === "general" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
@@ -101,9 +156,9 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "settings" && (
{/* Tab Content - Scrollable with min-height */}
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
{activeTab === "general" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
@@ -142,11 +197,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button
onClick={handleSave}
size="sm"
className="flex-1"
>
<Button onClick={handleSave} size="sm" className="flex-1">
Apply & Reload
</Button>
<Button
@@ -161,6 +212,371 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
)}
</div>
</div>
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
{(authRequired || authTokenStored) && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Authentication Token
</Label>
{!authRequired && authTokenStored && (
<span className="text-xs text-muted-foreground">
(Not required by current backend)
</span>
)}
</div>
{authTokenStored ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="password"
value="••••••••••••••••••••"
disabled
className="font-mono text-sm flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={handleClearAuthToken}
className="flex-shrink-0"
>
Clear
</Button>
</div>
<p className="text-xs text-green-600 dark:text-green-400">
Token configured and stored locally
</p>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
value={newAuthToken}
onChange={(e) => setNewAuthToken(e.target.value)}
placeholder="Enter bearer token"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && newAuthToken.trim()) {
handleAuthTokenSave();
}
}}
/>
<Button
onClick={handleAuthTokenSave}
size="sm"
disabled={!newAuthToken.trim()}
className="w-full"
>
Save & Reload
</Button>
<p className="text-xs text-muted-foreground">
{authRequired
? "Required by backend (started with --auth flag)"
: "Not required by current backend"}
</p>
</div>
)}
</div>
)}
{/* Deployment Setting */}
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Azure Deployment
</Label>
<p className="text-xs text-muted-foreground">
Enable one-click deployment to Azure Container Apps
</p>
</div>
<Switch
checked={azureDeploymentEnabled}
onCheckedChange={setAzureDeploymentEnabled}
/>
</div>
{/* Expandable info section */}
<details className="group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Learn more about Azure deployment
</summary>
<div className="mt-3 space-y-3 pl-4">
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, agents that support deployment will show a "Deploy to Azure"
button. This allows you to deploy your agent to Azure Container Apps directly
from DevUI.
</p>
<div className="space-y-1.5">
<p className="text-xs font-medium">When enabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deploy to Azure" for supported agents</li>
<li>Requires Azure CLI and proper authentication</li>
<li>Backend must have deployment capabilities enabled</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">When disabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deployment Guide" for all agents</li>
<li>Provides Docker templates and manual deployment instructions</li>
<li>No backend deployment capabilities required</li>
</ul>
</div>
</div>
</details>
</div>
</div>
)}
{activeTab === "proxy" && (
<div className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base font-medium">
OpenAI Proxy Mode
</Label>
<p className="text-xs text-muted-foreground">
Route requests through DevUI backend to OpenAI API
</p>
</div>
<Switch
checked={oaiMode.enabled}
onCheckedChange={(checked: boolean) =>
setOAIMode({ ...oaiMode, enabled: checked })
}
/>
</div>
{/* Info box when disabled - prominent */}
{!oaiMode.enabled && (
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-2">
<p className="text-sm font-medium">
About OpenAI Proxy Mode
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, your chat requests are sent to your
DevUI backend{" "}
<span className="font-mono font-semibold">
({backendUrl})
</span>
, which then forwards them to OpenAI's API. This keeps
your{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
secure on the server instead of exposing it in the
browser.
</p>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Requirements:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>
Backend must have{" "}
<span className="font-mono">OPENAI_API_KEY</span>{" "}
configured
</li>
<li>
Backend must support OpenAI Responses API proxying
(DevUI does)
</li>
</ul>
</div>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Why use this?</p>
<p className="text-xs text-muted-foreground">
Quickly test and compare OpenAI models directly
through the DevUI interface without creating custom
agents or exposing API keys in the browser.
</p>
</div>
</div>
</div>
</div>
)}
{oaiMode.enabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Model ID Input - Primary control */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model</Label>
<Input
type="text"
value={oaiMode.model}
onChange={(e) =>
setOAIMode({ ...oaiMode, model: e.target.value })
}
placeholder="gpt-4.1-mini"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
</p>
</div>
{/* Quick Preset Buttons */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Common presets
</Label>
<div className="flex flex-wrap gap-2">
{PRESET_MODELS.map((model) => (
<Button
key={model}
variant={
oaiMode.model === model ? "default" : "outline"
}
size="sm"
onClick={() => setOAIMode({ ...oaiMode, model })}
className="text-xs h-7"
>
{model}
</Button>
))}
</div>
</div>
{/* Advanced Parameters */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Advanced Parameters (optional)
</summary>
<div className="space-y-3 mt-3 pl-4">
{/* Temperature */}
<div className="space-y-1">
<Label className="text-xs">Temperature</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={oaiMode.temperature ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
temperature: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Controls randomness (0-2)
</p>
</div>
{/* Max Output Tokens */}
<div className="space-y-1">
<Label className="text-xs">Max Output Tokens</Label>
<Input
type="number"
min="1"
value={oaiMode.max_output_tokens ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
max_output_tokens: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Auto"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens in response
</p>
</div>
{/* Top P */}
<div className="space-y-1">
<Label className="text-xs">Top P</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={oaiMode.top_p ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
top_p: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Nucleus sampling (0-1)
</p>
</div>
{/* Reasoning Effort */}
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
<select
value={oaiMode.reasoning_effort ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
reasoning_effort: e.target.value
? (e.target.value as "minimal" | "low" | "medium" | "high")
: undefined,
})
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Auto (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<p className="text-xs text-muted-foreground">
Constrains reasoning effort (faster/cheaper vs thorough)
</p>
</div>
</div>
</details>
</div>
)}
</div>
{/* Collapsed info at bottom when enabled */}
{oaiMode.enabled && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p>
Requests route through{" "}
<span className="font-mono font-semibold">
{backendUrl}
</span>{" "}
to OpenAI API. Server must have{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
configured.
</p>
</div>
</div>
)}
</div>
)}
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -1,126 +0,0 @@
import { useMemo, useState, useCallback } from "react";
import type { ExtendedResponseStreamEvent } from "@/types";
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
// Type for executor input/output data - can be various types based on workflow events
export type ExecutorData =
| string
| number
| boolean
| Record<string, unknown>
| null;
// State tracking for a specific executor
interface ExecutorState {
executorId: string;
state: "pending" | "running" | "completed" | "failed" | "cancelled";
inputData?: ExecutorData;
outputData?: ExecutorData;
error?: string;
timestamp: string;
}
interface WorkflowEventCorrelationResult {
// State access
isWorkflowRunning: boolean;
selectedExecutorId: string | null;
recentlyActive: string[];
// Actions
selectExecutor: (executorId: string) => void;
getExecutorData: (executorId: string) => ExecutorState | null;
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
}
// Hook for correlating workflow events with executor states
export function useWorkflowEventCorrelation(
events: ExtendedResponseStreamEvent[],
isStreaming: boolean
): WorkflowEventCorrelationResult {
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
// Process events into executor states
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
const executorMap: Record<string, ExecutorState> = {};
const activeExecutors: string[] = [];
let workflowActive = isStreaming;
// Process workflow events
events.forEach((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
const executorId = data.executor_id;
if (!executorId) return;
// Initialize executor if not exists
if (!executorMap[executorId]) {
executorMap[executorId] = {
executorId,
state: "pending",
timestamp: new Date().toISOString(),
};
}
const executor = executorMap[executorId];
const eventType = data.event_type;
// Update state based on event type
if (eventType === "ExecutorInvokedEvent") {
executor.state = "running";
executor.inputData = data.data;
if (!activeExecutors.includes(executorId)) {
activeExecutors.push(executorId);
}
} else if (eventType === "ExecutorCompletedEvent") {
executor.state = "completed";
executor.outputData = data.data;
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
executor.state = "failed";
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
} else if (eventType?.includes("Cancel")) {
executor.state = "cancelled";
}
executor.timestamp = new Date().toISOString();
}
});
return {
executors: executorMap,
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
isWorkflowRunning: workflowActive,
};
}, [events, isStreaming]);
const selectExecutor = useCallback((executorId: string) => {
setSelectedExecutorId(executorId);
}, []);
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
return executors[executorId] || null;
}, [executors]);
const getExecutorEvents = useCallback(
(executorId: string): ExtendedResponseStreamEvent[] => {
return events.filter((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === executorId;
}
return false;
});
},
[events]
);
return {
isWorkflowRunning,
selectedExecutorId,
recentlyActive,
selectExecutor,
getExecutorData,
getExecutorEvents,
};
}
@@ -8,6 +8,7 @@ import type {
AgentSource,
Conversation,
HealthResponse,
MetaResponse,
RunAgentRequest,
RunWorkflowRequest,
WorkflowInfo,
@@ -32,6 +33,9 @@ interface BackendEntityInfo {
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
@@ -64,8 +68,8 @@ const DEFAULT_API_BASE_URL =
: ""; // Default to relative URLs (same host as frontend)
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Retry every second
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
// Get backend URL from localStorage or default
function getBackendUrl(): string {
@@ -82,9 +86,12 @@ function sleep(ms: number): Promise<void> {
class ApiClient {
private baseUrl: string;
private authToken: string | null = null;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || getBackendUrl();
// Load auth token from localStorage on initialization
this.authToken = localStorage.getItem("devui_auth_token");
}
// Allow updating the base URL at runtime
@@ -96,27 +103,68 @@ class ApiClient {
return this.baseUrl;
}
// Set auth token and persist to localStorage
setAuthToken(token: string | null): void {
this.authToken = token;
if (token) {
localStorage.setItem("devui_auth_token", token);
} else {
localStorage.removeItem("devui_auth_token");
}
}
// Get current auth token
getAuthToken(): string | null {
return this.authToken;
}
// Clear auth token
clearAuthToken(): void {
this.setAuthToken(null);
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
// Build headers with auth token if available
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
headers,
});
if (!response.ok) {
// Handle 401 Unauthorized - clear invalid token
if (response.status === 401) {
this.clearAuthToken();
throw new Error("UNAUTHORIZED");
}
// Try to extract error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
// Handle detail as string or object
if (errorData.detail) {
errorMessage = errorData.detail;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
errorMessage = errorData.detail.error.message;
}
} else if (errorData.error?.message) {
errorMessage = errorData.error.message;
}
} catch {
// If parsing fails, use default message
@@ -132,6 +180,11 @@ class ApiClient {
return this.request<HealthResponse>("/health");
}
// Server metadata
async getMeta(): Promise<MetaResponse> {
return this.request<MetaResponse>("/meta");
}
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | WorkflowInfo)[];
@@ -140,17 +193,14 @@ class ApiClient {
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: WorkflowInfo[] = [];
response.entities.forEach((entity) => {
// Transform entities while preserving backend order
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
if (entity.type === "agent") {
agents.push({
return {
id: entity.id,
name: entity.name,
description: entity.description,
type: "agent",
type: "agent" as const,
source: (entity.source as AgentSource) || "directory",
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -161,22 +211,26 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
// Agent-specific fields
instructions: entity.instructions,
model: entity.model,
chat_client_type: entity.chat_client_type,
context_providers: entity.context_providers,
middleware: entity.middleware,
});
} else if (entity.type === "workflow") {
};
} else {
// Workflow
const firstTool = entity.tools?.[0];
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
workflows.push({
return {
id: entity.id,
name: entity.name,
description: entity.description,
type: "workflow",
type: "workflow" as const,
source: (entity.source as AgentSource) || "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -187,17 +241,24 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
}, // Default schema
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
});
};
}
});
return { entities: [...agents, ...workflows], agents, workflows };
// Create filtered arrays for backward compatibility
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
return { entities, agents, workflows };
}
// Legacy methods for compatibility
@@ -225,6 +286,16 @@ class ApiClient {
);
}
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
// Hot reload entity - clears cache and forces reimport on next access
return this.request<{ success: boolean; message: string }>(
`/v1/entities/${entityId}/reload`,
{
method: "POST",
}
);
}
// ========================================
// Conversation Management (OpenAI Standard)
// ========================================
@@ -232,10 +303,23 @@ class ApiClient {
async createConversation(
metadata?: Record<string, string>
): Promise<Conversation> {
// Check if OAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
const headers: Record<string, string> = {};
// Add proxy mode header if enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
const response = await this.request<ConversationApiResponse>(
"/v1/conversations",
{
method: "POST",
headers,
body: JSON.stringify({ metadata }),
}
);
@@ -315,6 +399,19 @@ class ApiClient {
return this.request<{ data: unknown[]; has_more: boolean }>(url);
}
async deleteConversationItem(
conversationId: string,
itemId: string
): Promise<void> {
const response = await fetch(
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
{ method: "DELETE" }
);
if (!response.ok) {
throw new Error(`Failed to delete item: ${response.statusText}`);
}
}
// OpenAI-compatible streaming methods using /v1/responses endpoint
// Private helper method that handles the actual streaming with retry logic
@@ -323,6 +420,35 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Check if OpenAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
// Modify request if OAI mode is enabled
if (oaiMode.enabled) {
// Override model with OAI model
openAIRequest.model = oaiMode.model;
// Merge optional OpenAI parameters
if (oaiMode.temperature !== undefined) {
openAIRequest.temperature = oaiMode.temperature;
}
if (oaiMode.max_output_tokens !== undefined) {
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
}
if (oaiMode.top_p !== undefined) {
openAIRequest.top_p = oaiMode.top_p;
}
if (oaiMode.instructions !== undefined) {
openAIRequest.instructions = oaiMode.instructions;
}
// Reasoning parameters (for o-series models)
if (oaiMode.reasoning_effort !== undefined) {
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
}
}
let lastSequenceNumber = -1;
let retryCount = 0;
let hasYieldedAnyEvent = false;
@@ -367,26 +493,68 @@ class ApiClient {
params.set("starting_after", lastSequenceNumber.toString());
}
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "GET",
headers: {
Accept: "text/event-stream",
},
headers,
});
} else {
const url = `${this.baseUrl}/v1/responses`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
// Add proxy header if OAI mode is enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
headers,
body: JSON.stringify(openAIRequest),
});
}
if (!response.ok) {
// Try to extract detailed error message from response body
// Handle authentication errors - don't retry these
if (response.status === 401) {
this.clearAuthToken(); // Clear invalid token
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
}
// Handle other client errors (400-499) - don't retry these either
if (response.status >= 400 && response.status < 500) {
let errorMessage = `Client error ${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
}
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
}
// Server errors (500-599) - these can be retried
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = await response.json();
@@ -519,18 +687,26 @@ class ApiClient {
reader.releaseLock();
}
} catch (error) {
// Network error occurred - prepare to retry
const errorMessage = error instanceof Error ? error.message : String(error);
// Don't retry on auth errors or client errors
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
throw error; // Re-throw without retrying
}
// Network error or server error occurred - prepare to retry
retryCount++;
if (retryCount > MAX_RETRY_ATTEMPTS) {
// Max retries exceeded - give up
throw new Error(
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
);
}
// Wait before retrying
await sleep(RETRY_INTERVAL_MS);
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
await sleep(retryDelay);
// Loop will retry with GET if we have response_id, otherwise POST
}
}
@@ -559,6 +735,7 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Proxy mode handling is now inside streamOpenAIResponse
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
}
@@ -573,6 +750,9 @@ class ApiClient {
input: request.input_data || "", // Send dict directly, no stringification needed
stream: true,
conversation: request.conversation_id, // Include conversation if present
extra_body: request.checkpoint_id
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
: undefined, // Pass checkpoint_id if provided
};
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
@@ -613,6 +793,139 @@ class ApiClient {
clearStreamingState(conversationId: string): void {
clearStreamingState(conversationId);
}
// Deployment methods
async* streamDeployment(config: {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
}): AsyncGenerator<{
type: string;
message: string;
url?: string;
auth_token?: string;
}> {
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...config, stream: true }),
});
if (!response.ok) {
throw new Error(`Deployment failed: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch (e) {
// Emit error event for parsing failures
yield {
type: "deploy.error",
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
};
}
}
}
}
} catch (error) {
// Emit error event before throwing
yield {
type: "deploy.failed",
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
};
throw error;
} finally {
reader.releaseLock();
}
}
// ============================================================================
// Workflow Session Management (uses /conversations API)
// ============================================================================
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
// Workflow sessions are conversations with entity_id and type metadata
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
const response = await this.request<{
object: "list";
data: ConversationApiResponse[];
has_more: boolean;
}>(url);
// Transform conversations to WorkflowSession format (no checkpoint counting)
const sessions = response.data.map((conv) => ({
conversation_id: conv.id,
entity_id: conv.metadata?.entity_id || entityId,
created_at: conv.created_at,
metadata: {
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
description: conv.metadata?.description,
type: "workflow_session" as const,
},
}));
return { data: sessions };
}
async createWorkflowSession(
entityId: string,
params?: { name?: string; description?: string }
): Promise<import("@/types").WorkflowSession> {
// Create conversation with workflow session metadata
const metadata = {
entity_id: entityId,
type: "workflow_session" as const,
name: params?.name || `Session ${new Date().toLocaleString()}`,
...(params?.description && { description: params.description }),
};
const conversation = await this.createConversation(metadata);
return {
conversation_id: conversation.id,
entity_id: entityId,
created_at: conversation.created_at,
metadata: {
name: metadata.name,
description: metadata.description,
type: "workflow_session" as const,
},
};
}
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
// Delete conversation (this also deletes all associated items/checkpoints)
const success = await this.deleteConversation(conversationId);
if (!success) {
throw new Error("Failed to delete workflow session");
}
}
// Checkpoint operations now handled through standard conversation items API
// Checkpoints are conversation items with type="checkpoint"
}
// Export singleton instance
@@ -11,6 +11,9 @@ import type {
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
OAIProxyMode,
WorkflowSession,
CheckpointInfo,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
@@ -23,6 +26,7 @@ interface DevUIState {
// Entity Management Slice
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedAgent: AgentInfo | WorkflowInfo | undefined;
isLoadingEntities: boolean;
entityError: string | null;
@@ -42,8 +46,16 @@ interface DevUIState {
};
pendingApprovals: PendingApproval[];
// Workflow Session Slice (workflow-specific session management)
currentSession: WorkflowSession | undefined;
availableSessions: WorkflowSession[];
sessionCheckpoints: CheckpointInfo[];
loadingSessions: boolean;
loadingCheckpoints: boolean;
// UI Slice
showDebugPanel: boolean;
debugPanelMinimized: boolean;
debugPanelWidth: number;
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
@@ -53,6 +65,34 @@ interface DevUIState {
showGallery: boolean;
showDeployModal: boolean;
showEntityNotFoundToast: boolean;
// Toast Slice
toasts: Array<{
id: string;
message: string;
type: "info" | "success" | "warning" | "error";
duration?: number;
}>;
// OpenAI Proxy Mode Slice
oaiMode: OAIProxyMode;
// Server Meta Slice
uiMode: "developer" | "user";
serverCapabilities: {
tracing: boolean;
openai_proxy: boolean;
};
authRequired: boolean;
// Deployment Slice
isDeploying: boolean;
deploymentLogs: string[];
lastDeployment: {
url: string;
authToken: string;
} | null;
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
}
// ========================================
@@ -63,6 +103,7 @@ interface DevUIActions {
// Entity Actions
setAgents: (agents: AgentInfo[]) => void;
setWorkflows: (workflows: WorkflowInfo[]) => void;
setEntities: (entities: (AgentInfo | WorkflowInfo)[]) => void;
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
addAgent: (agent: AgentInfo) => void;
addWorkflow: (workflow: WorkflowInfo) => void;
@@ -84,8 +125,18 @@ interface DevUIActions {
updateConversationUsage: (tokens: number) => void;
setPendingApprovals: (approvals: PendingApproval[]) => void;
// Workflow Session Actions
setCurrentSession: (session: WorkflowSession | undefined) => void;
setAvailableSessions: (sessions: WorkflowSession[]) => void;
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
setLoadingSessions: (loading: boolean) => void;
setLoadingCheckpoints: (loading: boolean) => void;
addSession: (session: WorkflowSession) => void;
removeSession: (conversationId: string) => void;
// UI Actions
setShowDebugPanel: (show: boolean) => void;
setDebugPanelMinimized: (minimized: boolean) => void;
setDebugPanelWidth: (width: number) => void;
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
clearDebugEvents: () => void;
@@ -97,6 +148,29 @@ interface DevUIActions {
setShowDeployModal: (show: boolean) => void;
setShowEntityNotFoundToast: (show: boolean) => void;
// Toast Actions
addToast: (toast: {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}) => void;
removeToast: (id: string) => void;
// OpenAI Proxy Mode Actions
setOAIMode: (config: OAIProxyMode) => void;
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; capabilities: { tracing: boolean; openai_proxy: boolean }; authRequired: boolean }) => void;
// Deployment Actions
startDeployment: () => void;
addDeploymentLog: (log: string) => void;
setDeploymentResult: (result: { url: string; authToken: string }) => void;
stopDeployment: () => void;
clearDeploymentState: () => void;
setAzureDeploymentEnabled: (enabled: boolean) => void;
// Combined Actions (handle multiple state updates + side effects)
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
}
@@ -118,6 +192,7 @@ export const useDevUIStore = create<DevUIStore>()(
// Entity State
agents: [],
workflows: [],
entities: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
@@ -134,8 +209,16 @@ export const useDevUIStore = create<DevUIStore>()(
conversationUsage: { total_tokens: 0, message_count: 0 },
pendingApprovals: [],
// Workflow Session State
currentSession: undefined,
availableSessions: [],
sessionCheckpoints: [],
loadingSessions: false,
loadingCheckpoints: false,
// UI State
showDebugPanel: true,
debugPanelMinimized: false,
debugPanelWidth: 320,
debugEvents: [],
isResizing: false,
@@ -146,12 +229,36 @@ export const useDevUIStore = create<DevUIStore>()(
showDeployModal: false,
showEntityNotFoundToast: false,
// Toast State
toasts: [],
// OpenAI Proxy Mode State
oaiMode: {
enabled: false,
model: "gpt-4o-mini", // Default to cheaper model
},
// Server Meta State
uiMode: "developer", // Default to developer mode
serverCapabilities: {
tracing: false,
openai_proxy: false,
},
authRequired: false,
// Deployment State
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
azureDeploymentEnabled: false, // Default to disabled for safety
// ========================================
// Entity Actions
// ========================================
setAgents: (agents) => set({ agents }),
setWorkflows: (workflows) => set({ workflows }),
setEntities: (entities) => set({ entities }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
addAgent: (agent) =>
set((state) => ({ agents: [...state.agents, agent] })),
@@ -216,14 +323,69 @@ export const useDevUIStore = create<DevUIStore>()(
})),
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
// ========================================
// Workflow Session Actions
// ========================================
setCurrentSession: (session) => set({ currentSession: session }),
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
setSessionCheckpoints: (checkpoints) =>
set({ sessionCheckpoints: checkpoints }),
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
addSession: (session) =>
set((state) => ({
availableSessions: [session, ...state.availableSessions],
})),
removeSession: (conversationId) =>
set((state) => ({
availableSessions: state.availableSessions.filter(
(s) => s.conversation_id !== conversationId
),
// Clear current session if it's the one being deleted
currentSession:
state.currentSession?.conversation_id === conversationId
? undefined
: state.currentSession,
// Clear checkpoints if they belong to deleted session
sessionCheckpoints:
state.currentSession?.conversation_id === conversationId
? []
: state.sessionCheckpoints,
})),
// ========================================
// UI Actions
// ========================================
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
addDebugEvent: (event) =>
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
set((state) => {
// Generate unique timestamp for each event
// Use current time + small increment to ensure uniqueness even for rapid events
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp = state.debugEvents.length > 0
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
: 0;
// Ensure new timestamp is always greater than the last one
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
return {
debugEvents: [
...state.debugEvents,
{
...event,
// Add UI display timestamp when event is received (Unix seconds)
// Each event gets a unique timestamp to preserve chronological order
_uiTimestamp: ('created_at' in event && event.created_at)
? event.created_at
: uniqueTimestamp,
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
],
};
}),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
@@ -237,6 +399,153 @@ export const useDevUIStore = create<DevUIStore>()(
setShowEntityNotFoundToast: (show) =>
set({ showEntityNotFoundToast: show }),
// ========================================
// Toast Actions
// ========================================
addToast: (toast) =>
set((state) => ({
toasts: [
...state.toasts,
{
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: toast.type || "info",
duration: toast.duration || 4000,
...toast,
},
],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
// ========================================
// OpenAI Proxy Mode Actions
// ========================================
setOAIMode: (config) =>
set((state) => {
// If enabling OAI mode, clear conversation state
if (config.enabled && !state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching to OAI mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// If disabling OAI mode, also clear state
if (!config.enabled && state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching back to local mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// Just update config (model, temperature, etc.) without clearing state
return { oaiMode: config };
}),
toggleOAIMode: () =>
set((state) => {
const newEnabled = !state.oaiMode.enabled;
return {
oaiMode: { ...state.oaiMode, enabled: newEnabled },
// Clear conversation state when toggling
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}),
// ========================================
// Server Meta Actions
// ========================================
setServerMeta: (meta) =>
set({
uiMode: meta.uiMode,
serverCapabilities: meta.capabilities,
authRequired: meta.authRequired,
}),
// ========================================
// Deployment Actions
// ========================================
startDeployment: () =>
set({
isDeploying: true,
deploymentLogs: [],
lastDeployment: null,
}),
addDeploymentLog: (log) =>
set((state) => ({
deploymentLogs: [...state.deploymentLogs, log],
})),
setDeploymentResult: (result) =>
set({
isDeploying: false,
lastDeployment: result,
}),
stopDeployment: () =>
set({
isDeploying: false,
}),
clearDeploymentState: () =>
set({
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
}),
setAzureDeploymentEnabled: (enabled) =>
set({ azureDeploymentEnabled: enabled }),
// ========================================
// Combined Actions
// ========================================
@@ -245,6 +554,7 @@ export const useDevUIStore = create<DevUIStore>()(
* Select an entity (agent/workflow) and handle all side effects:
* - Update selected entity
* - Clear conversation state (FIXES THE BUG!)
* - Clear session state (for workflows)
* - Clear debug events
* - Update URL
*/
@@ -261,6 +571,10 @@ export const useDevUIStore = create<DevUIStore>()(
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
// Clear workflow session state when switching entities
currentSession: undefined,
availableSessions: [], // Let WorkflowView reload sessions
sessionCheckpoints: [],
// Clear debug events when switching
debugEvents: [],
});
@@ -276,7 +590,10 @@ export const useDevUIStore = create<DevUIStore>()(
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelMinimized: state.debugPanelMinimized,
debugPanelWidth: state.debugPanelWidth,
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
}),
}
),
@@ -68,6 +68,7 @@ export type ResponseInputParam = ResponseInputItem[];
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
export interface AgentFrameworkExtraBody {
entity_id: string;
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
// input_data removed - now using standard input field for all data
}
@@ -85,8 +86,15 @@ export interface AgentFrameworkRequest {
metadata?: Record<string, unknown>;
temperature?: number;
max_output_tokens?: number;
top_p?: number;
tools?: Record<string, unknown>[];
// Reasoning parameters (for o-series models)
reasoning?: {
effort?: "minimal" | "low" | "medium" | "high";
summary?: "auto" | "concise" | "detailed";
};
// Agent Framework extension - strongly typed
extra_body?: AgentFrameworkExtraBody;
entity_id?: string; // Allow entity_id as top-level field too
@@ -32,6 +32,9 @@ export interface AgentInfo {
module_path?: string;
required_env_vars?: EnvVarRequirement[];
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields
instructions?: string;
model?: string;
@@ -71,6 +74,7 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
input_schema: JSONSchema; // JSON Schema for workflow input
input_type_name: string; // Human-readable input type name
start_executor_id: string; // Entry point executor ID
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
}
// OpenAI Conversations API (standard)
@@ -89,6 +93,22 @@ export interface RunAgentRequest {
export interface RunWorkflowRequest {
input_data: Record<string, unknown>;
conversation_id?: string;
checkpoint_id?: string;
}
// OpenAI Proxy Mode Configuration
export interface OAIProxyMode {
enabled: boolean;
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
// Optional OpenAI Responses API parameters
temperature?: number;
max_output_tokens?: number;
top_p?: number;
instructions?: string;
// Reasoning parameters (for o-series models)
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
@@ -133,6 +153,17 @@ export interface HealthResponse {
version: string;
}
export interface MetaResponse {
ui_mode: "developer" | "user";
version: string;
framework: string;
capabilities: {
tracing: boolean;
openai_proxy: boolean;
};
auth_required: boolean;
}
// Chat message types matching Agent Framework
export interface ChatMessage {
id: string;
@@ -175,3 +206,54 @@ export interface PendingApproval {
arguments: Record<string, unknown>;
};
}
// Deployment types
export interface DeploymentConfig {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
ui_enabled?: boolean;
stream?: boolean;
}
export interface DeploymentEvent {
type: string;
message: string;
url?: string;
auth_token?: string;
}
export interface Deployment {
id: string;
entity_id: string;
resource_group: string;
app_name: string;
region: string;
url: string;
status: string;
created_at: string;
error?: string;
}
// Workflow Session Management Types
export interface WorkflowSession {
conversation_id: string;
entity_id: string;
created_at: number;
metadata: {
name?: string;
description?: string;
type: "workflow_session";
[key: string]: unknown;
};
}
export interface CheckpointInfo {
checkpoint_id: string;
workflow_id: string;
timestamp: number;
iteration_count: number;
metadata?: Record<string, unknown>;
}
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
// Custom Agent Framework OpenAI event types with structured data
export interface ResponseWorkflowEventComplete {
type: "response.workflow_event.complete";
type: "response.workflow_event.completed";
data: {
event_type: string;
data?: Record<string, unknown>;
@@ -125,6 +125,32 @@ export interface ResponseFunctionToolCall {
status?: "in_progress" | "completed" | "incomplete";
}
// DevUI Extension: Output item types for response.output_item.added events
export interface ResponseOutputImageItem {
id: string;
type: "output_image";
image_url: string;
alt_text?: string;
mime_type: string;
}
export interface ResponseOutputFileItem {
id: string;
type: "output_file";
filename: string;
file_url?: string;
file_data?: string;
mime_type: string;
}
export interface ResponseOutputDataItem {
id: string;
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
// Workflow Item Types - flexible interface for any workflow item
export interface WorkflowItem {
type: string; // "executor_action", "workflow_action", "message", or any future type
@@ -147,24 +173,34 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
return item.type === "executor_action" && "executor_id" in item;
}
// OpenAI Responses API - Output Item Events
// Union of all possible output items
export type ResponseOutputItem =
| ResponseFunctionToolCall
| ResponseOutputImageItem
| ResponseOutputFileItem
| ResponseOutputDataItem
| ExecutorActionItem
| WorkflowItem;
// OpenAI Responses API - Output Item Added Event
// OpenAI standard: Output item added event (extended to support our output types)
export interface ResponseOutputItemAddedEvent {
type: "response.output_item.added";
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
export interface ResponseOutputItemDoneEvent {
type: "response.output_item.done";
item: WorkflowItem | ResponseFunctionToolCall | any;
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace_event.complete";
type: "response.trace.completed";
data: {
operation_name?: string;
duration_ms?: number;
@@ -179,7 +215,7 @@ export interface ResponseTraceEventComplete {
// New trace event format from backend
export interface ResponseTraceComplete {
type: "response.trace.complete";
type: "response.trace.completed";
data: {
type?: string;
span_id?: string;
@@ -244,6 +280,20 @@ export interface ResponseFunctionResultComplete {
timestamp?: string; // Optional ISO timestamp for UI display
}
// DevUI Extension: Workflow Requests Human Input (HIL)
export interface ResponseRequestInfoEvent {
type: "response.request_info.requested";
request_id: string;
source_executor_id: string;
request_type: string;
request_data: Record<string, unknown>;
request_schema: Record<string, unknown>;
item_id: string;
output_index: number;
sequence_number: number;
timestamp: string;
}
// DevUI Extension: Turn Separator (UI-only event for grouping)
export interface TurnSeparatorEvent {
type: "debug.turn_separator";
@@ -266,6 +316,7 @@ export type StructuredEvent =
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseRequestInfoEvent
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent
@@ -374,6 +425,18 @@ export interface MessageInputFile {
filename?: string;
}
// DevUI Extension: Function approval request content (shown in chat)
export interface MessageFunctionApprovalRequestContent {
type: "function_approval_request";
request_id: string;
status: "pending" | "approved" | "rejected";
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
// DevUI Extension: Function approval response content
export interface MessageFunctionApprovalResponseContent {
type: "function_approval_response";
@@ -386,12 +449,45 @@ export interface MessageFunctionApprovalResponseContent {
};
}
// ============================================================================
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
// ============================================================================
// These extend the OpenAI Responses API to support rich content outputs
// that aren't natively supported (images, files, data). They mirror the
// input types but for agent outputs.
export interface MessageOutputImage {
type: "output_image";
image_url: string; // URL or data URI (data:image/png;base64,...)
alt_text?: string;
mime_type: string;
}
export interface MessageOutputFile {
type: "output_file";
filename: string;
file_url?: string;
file_data?: string; // base64
mime_type: string;
}
export interface MessageOutputData {
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
export type MessageContent =
| MessageTextContent
| MessageInputTextContent
| MessageOutputTextContent
| MessageInputImage
| MessageInputFile
| MessageOutputImage
| MessageOutputFile
| MessageOutputData
| MessageFunctionApprovalRequestContent
| MessageFunctionApprovalResponseContent;
// Message item (user/assistant messages with content)
@@ -401,6 +497,7 @@ export interface ConversationMessage {
role: "user" | "assistant" | "system" | "tool";
content: MessageContent[];
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this message was created
usage?: {
input_tokens: number;
output_tokens: number;
@@ -416,6 +513,7 @@ export interface ConversationFunctionCall {
name: string;
arguments: string;
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function call was made
}
// Function call output item
@@ -425,6 +523,7 @@ export interface ConversationFunctionCallOutput {
call_id: string;
output: string;
status?: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function result was received
}
// Union of all conversation item types
@@ -164,6 +164,8 @@ export function convertWorkflowDumpToEdges(
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: "default",
animated: false,
style: {
@@ -307,7 +309,7 @@ export function applyDagreLayout(
/**
* Process workflow events and extract node updates
* Handles both new standard OpenAI events and legacy workflow events
* Handles both standard OpenAI events and fallback workflow_event format
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[],
@@ -316,12 +318,29 @@ export function processWorkflowEvents(
const nodeUpdates: Record<string, NodeUpdate> = {};
let hasWorkflowStarted = false;
// Track the latest item ID for each executor to handle multiple runs
const latestItemIds: Record<string, string> = {};
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const itemId = item.id;
// Track the latest item ID for this executor
if (event.type === "response.output_item.added") {
latestItemIds[executorId] = itemId;
}
// Only process this event if it's for the latest item ID of this executor
// This prevents older "done" events from overwriting newer "added" events
const isLatestItem = latestItemIds[executorId] === itemId;
if (!isLatestItem && event.type === "response.output_item.done") {
return; // Skip this old completion event
}
let state: ExecutorState = "pending";
let error: string | undefined;
@@ -352,9 +371,9 @@ export function processWorkflowEvents(
else if (event.type === "response.created" || event.type === "response.in_progress") {
hasWorkflowStarted = true;
}
// Legacy support for older backends
// Handle workflow event format
else if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -400,16 +419,38 @@ 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)
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
// ONLY if it hasn't received any explicit executor events
// This prevents overwriting the actual state after the executor has run
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
// Additional check: only set to running if we don't have completion/failure events for this executor
// This prevents setting to "running" after the executor has already completed
const hasCompletionEvent = events.some((event) => {
if (event.type === "response.output_item.done") {
const item = (event as any).item;
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
data.event_type?.includes("Error") ||
data.event_type?.includes("Failed"));
}
return false;
});
// Only set to running if the executor hasn't completed yet
if (!hasCompletionEvent) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
}
return nodeUpdates;
@@ -466,9 +507,9 @@ export function getCurrentlyExecutingExecutors(
};
}
}
// Legacy support for older backends
// Handle workflow event format
else if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -515,7 +556,7 @@ export function updateEdgesWithSequenceAnalysis(
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -584,3 +625,67 @@ export function updateEdgesWithSequenceAnalysis(
};
});
}
/**
* Consolidate bidirectional edges into single edges with arrows on both ends
* This reduces visual clutter when edges go in both directions between nodes
*
* Smart handle selection algorithm:
* The current implementation keeps whichever edge was encountered first in the array.
* Since edges are typically created in workflow definition order (following the primary flow),
* this naturally keeps the "forward" edge and discards the "backward" one.
*
* For example, if the workflow defines:
* 1. coordinator planner (primary flow)
* 2. planner coordinator (feedback loop)
*
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
* the natural outputinput handle connection of the primary flow direction.
*
* React Flow will automatically route the edge to avoid overlaps, and the
* bidirectional arrows indicate that communication flows both ways.
*/
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
const edgeMap = new Map<string, Edge>();
const bidirectionalKeys = new Set<string>();
edges.forEach(edge => {
const forwardKey = `${edge.source}-${edge.target}`;
const reverseKey = `${edge.target}-${edge.source}`;
// Check if we already have the reverse edge
if (edgeMap.has(reverseKey)) {
// Mark both keys as bidirectional
bidirectionalKeys.add(reverseKey);
bidirectionalKeys.add(forwardKey);
// Update the existing reverse edge to be bidirectional
const existingEdge = edgeMap.get(reverseKey)!;
// Keep the existing edge's handles (they follow the primary workflow direction)
// Add bidirectional arrows to show two-way communication
edgeMap.set(reverseKey, {
...existingEdge,
markerStart: {
type: 'arrow' as const,
width: 20,
height: 20,
},
markerEnd: {
type: 'arrow' as const,
width: 20,
height: 20,
},
data: {
...existingEdge.data,
isBidirectional: true,
},
});
} else if (!bidirectionalKeys.has(forwardKey)) {
// Only add if this isn't the reverse of a bidirectional pair
edgeMap.set(forwardKey, edge);
}
});
return Array.from(edgeMap.values());
}
+21 -1
View File
@@ -584,13 +584,33 @@
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
version "1.2.3"
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@^1.1.13":
version "1.1.13"
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"
@@ -0,0 +1,443 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for checkpoint-as-conversation-items implementation."""
from dataclasses import dataclass
import pytest
from agent_framework import (
Executor,
InMemoryCheckpointStorage,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework_devui._conversations import (
CheckpointConversationManager,
InMemoryConversationStore,
)
@dataclass
class WorkflowTestData:
"""Simple test data."""
value: str
@dataclass
class WorkflowHILRequest:
"""HIL request for testing."""
question: str
class WorkflowTestExecutor(Executor):
"""Test executor with HIL."""
@handler
async def process(self, data: WorkflowTestData, ctx: WorkflowContext) -> None:
"""Process data and request approval."""
await ctx.set_executor_state({"data_value": data.value})
# Request HIL (checkpoint created here)
await ctx.request_info(request_data=WorkflowHILRequest(question=f"Approve {data.value}?"), response_type=str)
@response_handler
async def handle_response(
self, original_request: WorkflowHILRequest, response: str, ctx: WorkflowContext[str]
) -> None:
"""Handle HIL response."""
state = await ctx.get_executor_state() or {}
value = state.get("data_value", "")
await ctx.send_message(f"{value}_approved" if response.lower() == "yes" else f"{value}_rejected")
@pytest.fixture
def conversation_store():
"""Create in-memory conversation store."""
return InMemoryConversationStore()
@pytest.fixture
def checkpoint_manager(conversation_store):
"""Create checkpoint manager."""
return CheckpointConversationManager(conversation_store)
@pytest.fixture
def test_workflow():
"""Create test workflow with checkpointing."""
executor = WorkflowTestExecutor(id="test_executor")
checkpoint_storage = InMemoryCheckpointStorage()
return (
WorkflowBuilder(name="Test Workflow", description="Test checkpoint behavior")
.set_start_executor(executor)
.with_checkpointing(checkpoint_storage)
.build()
)
class TestCheckpointConversationManager:
"""Test CheckpointConversationManager functionality - CONVERSATION-SCOPED."""
@pytest.mark.asyncio
async def test_conversation_scoped_checkpoint_save(self, checkpoint_manager, test_workflow):
"""Test checkpoint save in a specific conversation."""
entity_id = "test_entity"
conversation_id = f"conv_{entity_id}_test123"
# Create conversation first
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Create test checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
)
# Get checkpoint storage for this conversation and save
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_id = await storage.save_checkpoint(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Verify checkpoint stored in THIS conversation only
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) == 1
assert checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@pytest.mark.asyncio
async def test_conversation_isolation(self, checkpoint_manager, test_workflow):
"""Test that conversations are isolated - checkpoints don't leak between conversations."""
entity_id = "test_entity"
conv_a = f"conv_{entity_id}_aaa"
conv_b = f"conv_{entity_id}_bbb"
# Create two conversations
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_a
)
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_b
)
# Save checkpoint to conversation A
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint_a = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"conversation": "A"},
)
storage_a = checkpoint_manager.get_checkpoint_storage(conv_a)
await storage_a.save_checkpoint(checkpoint_a)
# Verify conversation A has checkpoint
checkpoints_a = await storage_a.list_checkpoints()
assert len(checkpoints_a) == 1
# Verify conversation B has NO checkpoints (isolation)
storage_b = checkpoint_manager.get_checkpoint_storage(conv_b)
checkpoints_b = await storage_b.list_checkpoints()
assert len(checkpoints_b) == 0
@pytest.mark.asyncio
async def test_list_checkpoints_in_session(self, checkpoint_manager, test_workflow):
"""Test listing checkpoints within a session."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_test456"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Save multiple checkpoints
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_ids = []
for i in range(3):
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
checkpoint_ids.append(saved_id)
# List checkpoints using the storage
checkpoints_list = await storage.list_checkpoints()
assert len(checkpoints_list) == 3
# Verify all checkpoint IDs are present
loaded_ids = [cp.checkpoint_id for cp in checkpoints_list]
for saved_id in checkpoint_ids:
assert saved_id in loaded_ids
@pytest.mark.asyncio
async def test_checkpoints_appear_as_conversation_items(self, checkpoint_manager, test_workflow):
"""Test that checkpoints appear as conversation items through the standard API."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_items_test"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Save multiple checkpoints
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_ids = []
for i in range(2):
checkpoint = WorkflowCheckpoint(
checkpoint_id=f"checkpoint_{i}",
workflow_id=test_workflow.id,
messages={},
shared_state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
checkpoint_ids.append(saved_id)
# List conversation items - should include checkpoints
items, has_more = await checkpoint_manager.conversation_store.list_items(conversation_id)
# Filter for checkpoint items
checkpoint_items = [item for item in items if (isinstance(item, dict) and item.get("type") == "checkpoint")]
# Verify we have the correct number of checkpoint items
assert len(checkpoint_items) == 2, f"Expected 2 checkpoint items, got {len(checkpoint_items)}"
# Verify checkpoint items have correct structure
for item in checkpoint_items:
assert item.get("type") == "checkpoint"
assert item.get("checkpoint_id") in checkpoint_ids
assert item.get("workflow_id") == test_workflow.id
assert "timestamp" in item
assert item.get("id").startswith("checkpoint_") # ID format: checkpoint_{checkpoint_id}
@pytest.mark.asyncio
async def test_load_checkpoint_from_session(self, checkpoint_manager, test_workflow):
"""Test loading checkpoint from a specific session."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_test789"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Create and save a checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
original_checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"test_key": "test_value"},
)
# Save to this session
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
await storage.save_checkpoint(original_checkpoint)
# Load checkpoint from this session
loaded_checkpoint = await storage.load_checkpoint(original_checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == original_checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == original_checkpoint.workflow_id
assert loaded_checkpoint.shared_state == {"test_key": "test_value"}
class TestCheckpointStorage:
"""Test InMemoryCheckpointStorage per conversation - SESSION-SCOPED."""
@pytest.mark.asyncio
async def test_checkpoint_storage_protocol(self, checkpoint_manager, test_workflow):
"""Test that adapter implements CheckpointStorage protocol."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_adapter_test"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Get storage adapter for this session
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
# Create test checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
)
# Test save_checkpoint
checkpoint_id = await storage.save_checkpoint(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Test load_checkpoint
loaded = await storage.load_checkpoint(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
# Test list_checkpoint_ids
ids = await storage.list_checkpoint_ids(workflow_id=test_workflow.id)
assert checkpoint_id in ids
# Test list_checkpoints
checkpoints_list = await storage.list_checkpoints(workflow_id=test_workflow.id)
assert len(checkpoints_list) >= 1
assert any(cp.checkpoint_id == checkpoint_id for cp in checkpoints_list)
class TestIntegration:
"""Integration tests for checkpoint workflow execution."""
@pytest.mark.asyncio
async def test_manual_checkpoint_save_via_injected_storage(self, checkpoint_manager, test_workflow):
"""Test manual checkpoint save via build-time storage injection."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test1"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Get checkpoint storage for this session
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
# Set build-time storage (equivalent to .with_checkpointing() at build time)
# Note: In production, DevUI uses runtime injection via run_stream() parameter
if hasattr(test_workflow, "_runner") and hasattr(test_workflow._runner, "context"):
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Create and save a checkpoint via injected storage
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"injected": True}
)
await checkpoint_storage.save_checkpoint(checkpoint)
# Verify checkpoint is accessible via storage (in this session)
storage_checkpoints = await checkpoint_storage.list_checkpoints()
assert len(storage_checkpoints) > 0
assert storage_checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@pytest.mark.asyncio
async def test_checkpoint_roundtrip_via_storage(self, checkpoint_manager, test_workflow):
"""Test checkpoint save/load roundtrip via storage adapter."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test2"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Set build-time storage for testing
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Create checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"ready_to_resume": True},
)
checkpoint_id = await checkpoint_storage.save_checkpoint(checkpoint)
# Verify checkpoint can be loaded for resume
loaded = await checkpoint_storage.load_checkpoint(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
assert loaded.shared_state == {"ready_to_resume": True}
# Verify checkpoint is accessible via storage (for UI to list checkpoints)
checkpoints = await checkpoint_storage.list_checkpoints()
assert len(checkpoints) > 0
assert checkpoints[0].checkpoint_id == checkpoint_id
@pytest.mark.asyncio
async def test_workflow_auto_saves_checkpoints_to_injected_storage(self, checkpoint_manager, test_workflow):
"""Test that workflows automatically save checkpoints to our conversation-backed storage.
This is the critical end-to-end test that verifies the entire checkpoint flow:
1. Storage is set as build-time storage (simulates .with_checkpointing())
2. Workflow runs and pauses at HIL point (IDLE_WITH_PENDING_REQUESTS status)
3. Framework automatically saves checkpoint to our storage
4. Checkpoint is accessible via manager for UI to list/resume
Note: In production, DevUI passes checkpoint_storage to run_stream() as runtime parameter.
This test uses build-time injection to verify framework's checkpoint auto-save behavior.
"""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test3"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Set build-time storage to test automatic checkpoint saves
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Verify no checkpoints initially
checkpoints_before = await checkpoint_storage.list_checkpoints()
assert len(checkpoints_before) == 0
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
saw_request_event = False
async for event in test_workflow.run_stream(WorkflowTestData(value="test")):
if hasattr(event, "__class__"):
if event.__class__.__name__ == "RequestInfoEvent":
saw_request_event = True
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
is_status_event = event.__class__.__name__ == "WorkflowStatusEvent"
has_pending_status = hasattr(event, "status") and "IDLE_WITH_PENDING_REQUESTS" in str(event.status)
if is_status_event and has_pending_status:
break
assert saw_request_event, "Test workflow should have emitted RequestInfoEvent"
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
checkpoints_after = await checkpoint_storage.list_checkpoints()
assert len(checkpoints_after) > 0, "Workflow should have auto-saved checkpoint at HIL pause"
# Verify checkpoint has correct workflow_id
checkpoint = checkpoints_after[0]
assert checkpoint.workflow_id == test_workflow.id
@@ -0,0 +1,365 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for cleanup hook registration and execution."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
from agent_framework_devui import register_cleanup
from agent_framework_devui._discovery import EntityDiscovery
@pytest.fixture(autouse=True)
def cleanup_registry():
"""Clear the cleanup registry before each test."""
import agent_framework_devui
agent_framework_devui._cleanup_registry.clear()
yield
agent_framework_devui._cleanup_registry.clear()
class MockAgent:
"""Mock agent for testing."""
def __init__(self, name: str = "TestAgent"):
self.id = f"test-{name.lower()}"
self.name = name
self.description = "Test agent for cleanup hooks"
self.cleanup_called = False
self.async_cleanup_called = False
async def run_stream(self, messages=None, *, thread=None, **kwargs):
"""Mock streaming run method."""
yield AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test response")])],
inner_messages=[],
)
class MockCredential:
"""Mock credential object for testing cleanup."""
def __init__(self):
self.closed = False
async def close(self):
"""Mock async close method."""
self.closed = True
class MockSyncResource:
"""Mock synchronous resource for testing cleanup."""
def __init__(self):
self.closed = False
def close(self):
"""Mock sync close method."""
self.closed = True
# Test 1: Register single cleanup hook
async def test_register_cleanup_single_hook():
"""Test registering a single cleanup hook for an entity."""
agent = MockAgent("SingleHook")
credential = MockCredential()
# Register cleanup
register_cleanup(agent, credential.close)
# Verify credential not closed yet
assert not credential.closed
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get cleanup hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 1
# Execute hook
await hooks[0]()
assert credential.closed
# Test 2: Register multiple cleanup hooks
async def test_register_cleanup_multiple_hooks():
"""Test registering multiple cleanup hooks for a single entity."""
agent = MockAgent("MultipleHooks")
credential1 = MockCredential()
credential2 = MockCredential()
sync_resource = MockSyncResource()
# Register multiple hooks at once
register_cleanup(agent, credential1.close, credential2.close, sync_resource.close)
# Verify nothing closed yet
assert not credential1.closed
assert not credential2.closed
assert not sync_resource.closed
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get and execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 3
# Execute all hooks
for hook in hooks:
if asyncio.iscoroutinefunction(hook):
await hook()
else:
hook()
assert credential1.closed
assert credential2.closed
assert sync_resource.closed
# Test 3: Register cleanup hooks incrementally
async def test_register_cleanup_incremental():
"""Test registering cleanup hooks in multiple calls."""
agent = MockAgent("IncrementalHooks")
credential1 = MockCredential()
credential2 = MockCredential()
# Register hooks incrementally
register_cleanup(agent, credential1.close)
register_cleanup(agent, credential2.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Should have both hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
# Execute all hooks
for hook in hooks:
await hook()
assert credential1.closed
assert credential2.closed
# Test 4: Test with no cleanup hooks
async def test_no_cleanup_hooks():
"""Test entity without any cleanup hooks registered."""
agent = MockAgent("NoHooks")
# Don't register any cleanup hooks
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Should return empty list
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 0
# Test 5: Test cleanup with async and sync hooks mixed
async def test_mixed_async_sync_hooks():
"""Test that both async and sync cleanup hooks work together."""
agent = MockAgent("MixedHooks")
async_resource = MockCredential()
sync_resource = MockSyncResource()
# Register both types
register_cleanup(agent, async_resource.close, sync_resource.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get and execute hooks with proper async/sync handling
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
import inspect
for hook in hooks:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
assert async_resource.closed
assert sync_resource.closed
# Test 6: Test error handling in cleanup hooks
async def test_cleanup_hook_error_handling():
"""Test that errors in cleanup hooks don't break execution."""
agent = MockAgent("ErrorHooks")
credential = MockCredential()
def failing_hook():
raise RuntimeError("Intentional error for testing")
# Register failing hook and valid hook
register_cleanup(agent, failing_hook, credential.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
# Execute hooks with error handling (like _server.py does)
import inspect
for hook in hooks:
try:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
except Exception:
pass # Ignore errors like the server does
# Second hook should still execute despite first one failing
await credential.close()
assert credential.closed
# Test 7: Test ValueError when no hooks provided
def test_register_cleanup_no_hooks_error():
"""Test that register_cleanup raises ValueError when no hooks provided."""
agent = MockAgent("NoHooksError")
with pytest.raises(ValueError, match="At least one cleanup hook required"):
register_cleanup(agent)
# Test 8: Test file-based discovery with cleanup hooks
async def test_cleanup_with_file_based_discovery():
"""Test that cleanup hooks work with file-based entity discovery."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agent directory
agent_dir = temp_path / "test_agent"
agent_dir.mkdir()
# Write agent module with cleanup registration
agent_file = agent_dir / "__init__.py"
agent_file.write_text("""
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
from agent_framework_devui import register_cleanup
class MockCredential:
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
# Create credential and agent
credential = MockCredential()
class TestAgent:
id = "test-agent"
name = "Test Agent"
description = "Test agent with cleanup"
async def run_stream(self, messages=None, *, thread=None, **kwargs):
yield AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test")])],
inner_messages=[],
)
agent = TestAgent()
# Register cleanup at module level
register_cleanup(agent, credential.close)
""")
# Discover entities
discovery = EntityDiscovery(str(temp_path))
await discovery.discover_entities()
# Load the entity (triggers module import)
await discovery.load_entity("test_agent")
# Verify cleanup hooks were registered
hooks = discovery.get_cleanup_hooks("test_agent")
assert len(hooks) == 1
# Test 9: Test cleanup execution order
async def test_cleanup_execution_order():
"""Test that cleanup hooks execute in registration order."""
agent = MockAgent("OrderTest")
execution_order = []
def hook1():
execution_order.append(1)
def hook2():
execution_order.append(2)
def hook3():
execution_order.append(3)
# Register in specific order
register_cleanup(agent, hook1, hook2, hook3)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
for hook in hooks:
hook()
# Verify execution order
assert execution_order == [1, 2, 3]
# Test 10: Test custom cleanup logic
async def test_custom_cleanup_logic():
"""Test registering custom cleanup function with complex logic."""
agent = MockAgent("CustomCleanup")
cleanup_executed = False
resources_closed = []
async def custom_cleanup():
nonlocal cleanup_executed
cleanup_executed = True
resources_closed.append("credential")
resources_closed.append("session")
resources_closed.append("cache")
register_cleanup(agent, custom_cleanup)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 1
await hooks[0]()
assert cleanup_executed
assert resources_closed == ["credential", "session", "cache"]
@@ -415,6 +415,56 @@ async def test_executor_action_events(mapper: MessageMapper, test_request: Agent
assert "Executor failed" in str(events[0].item["error"]["message"])
async def test_magentic_agent_delta_creates_message_container(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test that MagenticAgentDeltaEvent creates message containers (Option A implementation)."""
# Create mock MagenticAgentDeltaEvent that mimics the real class
from dataclasses import dataclass
try:
from agent_framework import WorkflowEvent
@dataclass
class MagenticAgentDeltaEvent(WorkflowEvent): # Inherit from WorkflowEvent
agent_id: str
text: str | None = None
except ImportError:
# Fallback if WorkflowEvent is not available
@dataclass
class MagenticAgentDeltaEvent: # Use the expected name directly
agent_id: str
text: str | None = None
# First delta should create message container
first_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="Hello ")
events = await mapper.convert_event(first_delta, test_request)
# Should emit 3 events: message container, content part, and text delta
assert len(events) == 3
assert events[0].type == "response.output_item.added"
assert events[0].item.type == "message" # Message, not executor_action!
assert events[0].item.metadata["agent_id"] == "test_agent"
assert events[0].item.metadata["source"] == "magentic"
message_id = events[0].item.id
# Check text delta references the message ID
assert events[2].type == "response.output_text.delta"
assert events[2].item_id == message_id
assert events[2].delta == "Hello "
# Second delta should NOT create new container
second_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="world!")
events = await mapper.convert_event(second_delta, test_request)
# Only text delta, no new container
assert len(events) == 1
assert events[0].type == "response.output_text.delta"
assert events[0].item_id == message_id
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
@@ -4,13 +4,14 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import pytest
# Add parent package to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from agent_framework_devui._utils import generate_input_schema
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
@dataclass
@@ -132,6 +133,99 @@ def test_schema_generation_error_handling():
pass
def test_extract_response_type_from_executor():
"""Test extraction of response type from @response_handler methods."""
try:
from agent_framework import Executor, WorkflowContext, handler, response_handler
from pydantic import BaseModel, Field
# Define test request and response types
@dataclass
class TestApprovalRequest:
"""Test request for approval."""
prompt: str
context: str
class TestDecision(BaseModel):
"""Test decision response."""
decision: Literal["approve", "reject"] = Field(description="User's decision")
reason: str = Field(description="Reason for decision", default="")
# Create test executor with @response_handler
class TestExecutor(Executor):
"""Test executor with response handler."""
def __init__(self):
super().__init__(id="test_executor")
@handler
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
"""Regular handler to satisfy executor requirements."""
# Request info that will be handled by response_handler
request = TestApprovalRequest(prompt="Test", context="Test context")
await ctx.request_info(request, TestDecision)
@response_handler
async def handle_approval(
self, original_request: TestApprovalRequest, response: TestDecision, ctx: WorkflowContext
) -> None:
"""Handle approval response."""
pass
# Test extraction
executor = TestExecutor()
extracted_type = extract_response_type_from_executor(executor, TestApprovalRequest)
# Verify correct type was extracted
assert extracted_type is not None, "Should extract response type from @response_handler"
assert extracted_type == TestDecision, f"Expected TestDecision, got {extracted_type}"
# Test full schema generation pipeline
schema = generate_input_schema(extracted_type)
assert schema is not None
assert isinstance(schema, dict)
assert "properties" in schema
assert "decision" in schema["properties"]
assert "enum" in schema["properties"]["decision"]
assert schema["properties"]["decision"]["enum"] == ["approve", "reject"]
except ImportError as e:
pytest.skip(f"Required dependencies not available: {e}")
def test_extract_response_type_no_match():
"""Test that extraction returns None when no matching handler exists."""
try:
from agent_framework import Executor, WorkflowContext, handler
@dataclass
class UnmatchedRequest:
"""Request type with no handler."""
data: str
class MinimalExecutor(Executor):
"""Executor with a handler but no matching response_handler."""
def __init__(self):
super().__init__(id="minimal_executor")
@handler
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
"""Regular handler."""
pass
executor = MinimalExecutor()
extracted_type = extract_response_type_from_executor(executor, UnmatchedRequest)
assert extracted_type is None, "Should return None when no matching handler exists"
except ImportError as e:
pytest.skip(f"Required dependencies not available: {e}")
if __name__ == "__main__":
# Simple test runner for manual execution
pytest.main([__file__, "-v"])
+135
View File
@@ -241,6 +241,100 @@ async def test_multiple_credential_attributes() -> None:
assert mock_cred2.close.called, "Async credential should be closed"
def test_ui_mode_configuration():
"""Test UI mode configuration."""
dev_server = DevServer(mode="developer")
assert dev_server.mode == "developer"
user_server = DevServer(mode="user")
assert user_server.mode == "user"
@pytest.mark.asyncio
async def test_api_restrictions_in_user_mode():
"""Test that developer APIs are restricted in user mode."""
from fastapi.testclient import TestClient
# Create servers with different modes
dev_server = DevServer(mode="developer")
user_server = DevServer(mode="user")
dev_app = dev_server.create_app()
user_app = user_server.create_app()
dev_client = TestClient(dev_app)
user_client = TestClient(user_app)
# Test 1: Health endpoint should work in both modes
assert dev_client.get("/health").status_code == 200
assert user_client.get("/health").status_code == 200
# Test 2: Meta endpoint should reflect correct mode
dev_meta = dev_client.get("/meta").json()
assert dev_meta["ui_mode"] == "developer"
user_meta = user_client.get("/meta").json()
assert user_meta["ui_mode"] == "user"
# Test 3: Entity listing should work in both modes
assert dev_client.get("/v1/entities").status_code == 200
assert user_client.get("/v1/entities").status_code == 200
# Test 4: Entity info should be restricted in user mode
dev_response = dev_client.get("/v1/entities/test_agent/info")
assert dev_response.status_code in [200, 404, 500] # Not 403
user_response = user_client.get("/v1/entities/test_agent/info")
assert user_response.status_code == 403
error_data = user_response.json()
# FastAPI wraps HTTPException detail in 'detail' field
error = error_data.get("detail", {}).get("error") or error_data.get("error")
assert error is not None
assert "developer mode" in error["message"].lower()
assert error["code"] == "developer_mode_required"
# Test 5: Hot reload should be restricted in user mode
dev_response = dev_client.post("/v1/entities/test_agent/reload")
assert dev_response.status_code in [200, 404, 500] # Not 403
user_response = user_client.post("/v1/entities/test_agent/reload")
assert user_response.status_code == 403
error_data = user_response.json()
error = error_data.get("detail", {}).get("error") or error_data.get("error")
assert "developer mode" in error["message"].lower()
# Test 6: Deployment endpoints should be restricted in user mode
# List deployments (simplest test - no payload needed)
user_response = user_client.get("/v1/deployments")
assert user_response.status_code == 403
error_data = user_response.json()
error = error_data.get("detail", {}).get("error") or error_data.get("error")
assert "developer mode" in error["message"].lower()
# Get deployment
user_response = user_client.get("/v1/deployments/test-id")
assert user_response.status_code == 403
# Delete deployment
user_response = user_client.delete("/v1/deployments/test-id")
assert user_response.status_code == 403
# Test 7: Conversation endpoints should work in both modes
dev_response = dev_client.post("/v1/conversations", json={})
assert dev_response.status_code == 200
user_response = user_client.post("/v1/conversations", json={})
assert user_response.status_code == 200
# Test 8: Chat endpoint should work in both modes
chat_payload = {"model": "test_agent", "input": "Hello"}
dev_response = dev_client.post("/v1/responses", json=chat_payload)
assert dev_response.status_code in [200, 404] # 404 if agent doesn't exist
user_response = user_client.post("/v1/responses", json=chat_payload)
assert user_response.status_code in [200, 404]
if __name__ == "__main__":
# Simple test runner
async def run_tests():
@@ -273,3 +367,44 @@ class WeatherAgent:
await executor.execute_sync(request)
asyncio.run(run_tests())
@pytest.mark.asyncio
async def test_checkpoint_api_endpoints(test_entities_dir):
"""Test checkpoint list and delete API endpoints."""
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
server = DevServer(entities_dir=test_entities_dir)
executor = await server._ensure_executor()
# Create a conversation
conversation = executor.conversation_store.create_conversation(metadata={"name": "Test Session"})
conv_id = conversation.id
# Get checkpoint storage and add a checkpoint
storage = executor.checkpoint_manager.get_checkpoint_storage(conv_id)
checkpoint = WorkflowCheckpoint(
checkpoint_id="test_checkpoint_1",
workflow_id="test_workflow",
shared_state={"key": "value"},
iteration_count=1,
)
await storage.save_checkpoint(checkpoint)
# Test list checkpoints endpoint
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) == 1
assert checkpoints[0].checkpoint_id == "test_checkpoint_1"
assert checkpoints[0].workflow_id == "test_workflow"
# Test delete checkpoint endpoint
deleted = await storage.delete_checkpoint("test_checkpoint_1")
assert deleted is True
# Verify checkpoint was deleted
remaining = await storage.list_checkpoints()
assert len(remaining) == 0
# Test delete non-existent checkpoint
deleted = await storage.delete_checkpoint("nonexistent")
assert deleted is False
@@ -0,0 +1,19 @@
# Auto-generated Dockerfiles from DevUI deployment
*/Dockerfile
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Environment files (may contain secrets)
.env
*.env
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
@@ -2,22 +2,22 @@
"""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.
The following sample demonstrates a comprehensive 4-step workflow with multiple executors
that process, detect spam, and handle email messages. This workflow illustrates
complex branching logic with human-in-the-loop approval and realistic processing delays.
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
2. Spam Detector - Analyzes content and determines if the message is spam (with human approval)
3a. Spam Handler - Processes spam messages (quarantine, log, remove)
3b. Message Responder - Handles legitimate messages (validate, respond)
4. Final Processor - Completes the workflow with logging and cleanup
"""
import asyncio
import logging
from dataclasses import dataclass
from typing import Literal, Annotated
from agent_framework import (
Case,
@@ -26,10 +26,18 @@ from agent_framework import (
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from pydantic import BaseModel, Field
from typing_extensions import Never
# Define response model with clear user guidance
class SpamDecision(BaseModel):
"""User's decision on whether the email is spam."""
decision: Literal["spam", "not spam"] = Field(
description="Enter 'spam' to mark as spam, or 'not spam' to mark as legitimate"
)
@dataclass
class EmailContent:
@@ -41,25 +49,17 @@ class EmailContent:
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
email_content: EmailContent
is_spam: bool = False
confidence_score: float = 0.0
spam_reasons: list[str] | None = None
human_reviewed: bool = False
human_decision: str | None = None
ai_original_classification: bool = False
def __post_init__(self):
"""Initialize spam_reasons list if None."""
@@ -67,6 +67,16 @@ class SpamDetectorResponse:
self.spam_reasons = []
@dataclass
class SpamApprovalRequest:
"""Human-in-the-loop approval request for spam classification."""
email_message: str = ""
detected_as_spam: bool = False
confidence: float = 0.0
reasons: str = ""
@dataclass
class ProcessingResult:
"""A data class to hold the final processing result."""
@@ -78,6 +88,9 @@ class ProcessingResult:
is_spam: bool
confidence_score: float
spam_reasons: list[str]
was_human_reviewed: bool = False
human_override: str | None = None
ai_original_decision: bool = False
class EmailRequest(BaseModel):
@@ -115,18 +128,27 @@ class EmailPreprocessor(Executor):
await ctx.send_message(result)
class ContentAnalyzer(Executor):
"""Step 2: An executor that analyzes email content and structure."""
class SpamDetector(Executor):
"""Step 2: An executor that analyzes content and determines if a message is spam."""
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_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
async def handle_email_content(self, email_content: EmailContent, ctx: WorkflowContext[SpamApprovalRequest]) -> None:
"""Analyze email content and determine if the message is spam, then request human approval."""
await asyncio.sleep(2.0) # Simulate analysis and detection time
# Simulate content analysis
email_text = email_content.cleaned_message
# Analyze content for risk indicators
contains_links = "http" in email_text or "www" in email_text
has_attachments = "attachment" in email_text
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: list[str] = []
@@ -139,32 +161,7 @@ class ContentAnalyzer(Executor):
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
@@ -175,29 +172,100 @@ class SpamDetector(Executor):
spam_score += 0.4
spam_reasons.append(f"spam_keywords: {keyword_matches}")
if analysis.email_content.has_suspicious_patterns:
if email_content.has_suspicious_patterns:
spam_score += 0.3
spam_reasons.append("suspicious_patterns")
if len(analysis.risk_indicators) >= 3:
if len(risk_indicators) >= 3:
spam_score += 0.2
spam_reasons.append("high_risk_indicators")
if analysis.sentiment_score < 0.4:
if 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
# Store detection result in executor state for later use
# Store minimal data needed (not complex objects that don't serialize well)
await ctx.set_executor_state({
"original_message": email_content.original_message,
"cleaned_message": email_content.cleaned_message,
"word_count": email_content.word_count,
"has_suspicious_patterns": email_content.has_suspicious_patterns,
"is_spam": is_spam,
"ai_original_classification": is_spam, # Store original AI decision
"confidence_score": spam_score,
"spam_reasons": spam_reasons
})
# Request human approval before proceeding using new API
approval_request = SpamApprovalRequest(
email_message=email_text[:200], # First 200 chars
detected_as_spam=is_spam,
confidence=spam_score,
reasons=", ".join(spam_reasons) if spam_reasons else "no specific reasons"
)
await ctx.request_info(
request_data=approval_request,
response_type=SpamDecision,
)
@response_handler
async def handle_human_response(
self,
original_request: SpamApprovalRequest,
response: SpamDecision,
ctx: WorkflowContext[SpamDetectorResponse]
) -> None:
"""Process human approval response and continue workflow."""
print(f"[SpamDetector] handle_human_response called with response: {response}")
# Get stored detection result
state = await ctx.get_executor_state() or {}
print(f"[SpamDetector] Retrieved state: {state}")
ai_original = state.get("ai_original_classification", False)
confidence_score = state.get("confidence_score", 0.0)
spam_reasons = state.get("spam_reasons", [])
# Parse human decision from the response model
human_decision = response.decision.strip().lower()
# Determine final classification based on human input
if human_decision in ["not spam"]:
is_spam = False
elif human_decision in ["spam"]:
is_spam = True
else:
# Default to AI decision if unclear
is_spam = ai_original
# Reconstruct EmailContent from stored primitives
email_content = EmailContent(
original_message=state.get("original_message", ""),
cleaned_message=state.get("cleaned_message", ""),
word_count=state.get("word_count", 0),
has_suspicious_patterns=state.get("has_suspicious_patterns", False)
)
result = SpamDetectorResponse(
email_content=email_content,
is_spam=is_spam,
confidence_score=confidence_score,
spam_reasons=spam_reasons,
human_reviewed=True,
human_decision=response.decision,
ai_original_classification=ai_original
)
print(f"[SpamDetector] Sending SpamDetectorResponse: is_spam={is_spam}, confidence={confidence_score}, human_reviewed=True")
await ctx.send_message(result)
print(f"[SpamDetector] Message sent successfully")
class SpamHandler(Executor):
"""Step 4a: An executor that handles spam messages with quarantine and logging."""
"""Step 3a: An executor that handles spam messages with quarantine and logging."""
@handler
async def handle_spam_detection(
@@ -212,20 +280,23 @@ class SpamHandler(Executor):
await asyncio.sleep(2.2) # Simulate spam handling time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
original_message=spam_result.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 [],
was_human_reviewed=spam_result.human_reviewed,
human_override=spam_result.human_decision,
ai_original_decision=spam_result.ai_original_classification,
)
await ctx.send_message(result)
class MessageResponder(Executor):
"""Step 4b: An executor that responds to legitimate messages."""
class LegitimateMessageHandler(Executor):
"""Step 3b: An executor that handles legitimate (non-spam) messages."""
@handler
async def handle_spam_detection(
@@ -240,20 +311,23 @@ class MessageResponder(Executor):
await asyncio.sleep(2.5) # Simulate response time
result = ProcessingResult(
original_message=spam_result.analysis.email_content.original_message,
action_taken="responded_and_filed",
original_message=spam_result.email_content.original_message,
action_taken="delivered_to_inbox",
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 [],
was_human_reviewed=spam_result.human_reviewed,
human_override=spam_result.human_decision,
ai_original_decision=spam_result.ai_original_classification,
)
await ctx.send_message(result)
class FinalProcessor(Executor):
"""Step 5: An executor that completes the workflow with final logging and cleanup."""
"""Step 4: An executor that completes the workflow with final logging and cleanup."""
@handler
async def handle_processing_result(
@@ -266,50 +340,98 @@ class FinalProcessor(Executor):
total_time = result.processing_time + 1.5
# Include classification details in completion message
# Build classification status with human review info
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"
)
# Add human review context
review_status = ""
if result.was_human_reviewed:
if result.ai_original_decision != result.is_spam:
review_status = " (human-overridden)"
else:
review_status = " (human-verified)"
# Build appropriate message based on classification
if result.is_spam:
# For spam messages
spam_indicators = ", ".join(result.spam_reasons) if result.spam_reasons else "none detected"
if result.was_human_reviewed:
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
human_decision = result.human_override if result.human_override else "unknown"
completion_message = (
f"Email classified as {classification}{review_status}.\n"
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
f"Human reviewer: {human_decision}\n"
f"Spam indicators: {spam_indicators}\n"
f"Action: Message quarantined for review\n"
f"Processing time: {total_time:.1f}s"
)
else:
completion_message = (
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
f"Spam indicators: {spam_indicators}\n"
f"Action: Message quarantined for review\n"
f"Processing time: {total_time:.1f}s"
)
else:
# For legitimate messages
if result.was_human_reviewed:
ai_status = "SPAM" if result.ai_original_decision else "LEGITIMATE"
human_decision = result.human_override if result.human_override else "unknown"
completion_message = (
f"Email classified as {classification}{review_status}.\n"
f"AI detected: {ai_status} (confidence: {result.confidence_score:.2f})\n"
f"Human reviewer: {human_decision}\n"
f"Action: Delivered to inbox\n"
f"Processing time: {total_time:.1f}s"
)
else:
completion_message = (
f"Email classified as {classification} (confidence: {result.confidence_score:.2f}).\n"
f"Action: Delivered to inbox\n"
f"Processing time: {total_time:.1f}s"
)
await ctx.yield_output(completion_message)
# DevUI will provide checkpoint storage automatically via the new workflow API
# No need to create checkpoint storage here anymore!
# 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
# Create all the executors for the 4-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")
legitimate_message_handler = LegitimateMessageHandler(id="legitimate_message_handler")
final_processor = FinalProcessor(id="final_processor")
# Build the comprehensive 5-step workflow with branching logic
# Build the comprehensive 4-step workflow with branching logic and HIL support
# Note: No .with_checkpointing() call - DevUI will pass checkpoint_storage at runtime
workflow = (
WorkflowBuilder(
name="Email Spam Detector",
description="5-step email classification workflow with spam/legitimate routing",
description="4-step email classification workflow with human-in-the-loop spam approval",
)
.set_start_executor(email_preprocessor)
.add_edge(email_preprocessor, content_analyzer)
.add_edge(content_analyzer, spam_detector)
.add_edge(email_preprocessor, spam_detector)
# HIL handled within spam_detector via @response_handler
# Continue with branching logic after human approval
# Only route SpamDetectorResponse messages (not SpamApprovalRequest)
.add_switch_case_edge_group(
spam_detector,
[
Case(condition=lambda x: x.is_spam, target=spam_handler),
Default(target=message_responder),
Case(condition=lambda x: isinstance(x, SpamDetectorResponse) and x.is_spam, target=spam_handler),
Default(target=legitimate_message_handler), # Default handles non-spam and non-SpamDetectorResponse messages
],
)
.add_edge(spam_handler, final_processor)
.add_edge(message_responder, final_processor)
.add_edge(legitimate_message_handler, final_processor)
.build()
)
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sample weather agent for Agent Framework Debug UI."""
import logging
import os
from collections.abc import Awaitable, Callable
from typing import Annotated
@@ -14,8 +15,20 @@ from agent_framework import (
Role,
chat_middleware,
function_middleware,
ai_function
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_devui import register_cleanup
logger = logging.getLogger(__name__)
def cleanup_resources():
"""Cleanup function that runs when DevUI shuts down."""
logger.info("=" * 60)
logger.info(" Cleaning up resources...")
logger.info(" (In production, this would close credentials, sessions, etc.)")
logger.info("=" * 60)
@chat_middleware
@@ -93,6 +106,14 @@ def get_forecast(
return f"Weather forecast for {location}:\n" + "\n".join(forecast)
@ai_function(approval_mode="always_require")
def send_email(
recipient: Annotated[str, "The email address of the recipient."],
subject: Annotated[str, "The subject of the email."],
body: Annotated[str, "The body content of the email."],
) -> str:
"""Simulate sending an email."""
return f"Email sent to {recipient} with subject '{subject}'."
# Agent instance following Agent Framework conventions
agent = ChatAgent(
@@ -106,10 +127,13 @@ agent = ChatAgent(
chat_client=AzureOpenAIChatClient(
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
),
tools=[get_weather, get_forecast],
tools=[get_weather, get_forecast, send_email],
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
)
# Register cleanup hook - demonstrates resource cleanup on shutdown
register_cleanup(agent, cleanup_resources)
def main():
"""Launch the Azure weather agent in DevUI."""