Python: DevUI improvements. (#1091)

* enable deeplinking in ui, add agent details to entity info, add usage data, add middleware example in samples and foundry agent.

* update ui build

* Update python/packages/devui/frontend/src/components/workflow/workflow-input-form.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/devui/pyproject.toml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/devui/pyproject.toml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* imporove mapping for agent nodes and serialiation for agent run events

* lint fixes

* update pyproj toml and ui updates

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-10-03 15:22:03 -07:00
committed by GitHub
Unverified
parent 61ac6d43b2
commit 01f438d710
33 changed files with 2733 additions and 1164 deletions
@@ -134,6 +134,52 @@ class EntityDiscovery:
# Extract tools/executors using Agent Framework specific logic
tools_list = await self._extract_tools_from_object(entity_object, entity_type)
# Extract agent-specific fields (for agents only)
instructions = None
model = None
chat_client_type = None
context_providers_list = None
middleware_list = None
if entity_type == "agent":
# Try to get instructions
if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
instructions = entity_object.chat_options.instructions
# Try to get model - check both chat_options and chat_client
if (
hasattr(entity_object, "chat_options")
and hasattr(entity_object.chat_options, "model_id")
and entity_object.chat_options.model_id
):
model = entity_object.chat_options.model_id
elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
model = entity_object.chat_client.model_id
# Try to get chat client type
if hasattr(entity_object, "chat_client"):
chat_client_type = entity_object.chat_client.__class__.__name__
# Try to get context providers
if (
hasattr(entity_object, "context_provider")
and entity_object.context_provider
and hasattr(entity_object.context_provider, "__class__")
):
context_providers_list = [entity_object.context_provider.__class__.__name__]
# Try to get middleware
if hasattr(entity_object, "middleware") and entity_object.middleware:
middleware_list = []
for m in entity_object.middleware:
# Try multiple ways to get a good name for middleware
if hasattr(m, "__name__"): # Function or callable
middleware_list.append(m.__name__)
elif hasattr(m, "__class__"): # Class instance
middleware_list.append(m.__class__.__name__)
else:
middleware_list.append(str(m))
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
@@ -142,6 +188,11 @@ class EntityDiscovery:
type=entity_type,
framework="agent_framework",
tools=[str(tool) for tool in (tools_list or [])],
instructions=instructions,
model=model,
chat_client_type=chat_client_type,
context_providers=context_providers_list,
middleware=middleware_list,
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,
@@ -446,6 +497,48 @@ class EntityDiscovery:
if tools:
tools_union = [tool for tool in tools]
# Extract agent-specific fields (for agents only)
instructions = None
model = None
chat_client_type = None
context_providers_list = None
middleware_list = None
if obj_type == "agent":
# Try to get instructions
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "instructions"):
instructions = obj.chat_options.instructions
# Try to get model - check both chat_options and chat_client
if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "model_id") and obj.chat_options.model_id:
model = obj.chat_options.model_id
elif hasattr(obj, "chat_client") and hasattr(obj.chat_client, "model_id"):
model = obj.chat_client.model_id
# Try to get chat client type
if hasattr(obj, "chat_client"):
chat_client_type = obj.chat_client.__class__.__name__
# Try to get context providers
if (
hasattr(obj, "context_provider")
and obj.context_provider
and hasattr(obj.context_provider, "__class__")
):
context_providers_list = [obj.context_provider.__class__.__name__]
# Try to get middleware
if hasattr(obj, "middleware") and obj.middleware:
middleware_list = []
for m in obj.middleware:
# Try multiple ways to get a good name for middleware
if hasattr(m, "__name__"): # Function or callable
middleware_list.append(m.__name__)
elif hasattr(m, "__class__"): # Class instance
middleware_list.append(m.__class__.__name__)
else:
middleware_list.append(str(m))
entity_info = EntityInfo(
id=entity_id,
type=obj_type,
@@ -453,6 +546,11 @@ class EntityDiscovery:
framework="agent_framework",
description=description,
tools=tools_union,
instructions=instructions,
model=model,
chat_client_type=chat_client_type,
context_providers=context_providers_list,
middleware=middleware_list,
metadata={
"module_path": module_path,
"entity_type": obj_type,
@@ -151,6 +151,20 @@ class AgentFrameworkExecutor:
if not display_contents:
continue
# Extract usage information if present
usage_data = None
for content in af_msg.contents:
content_type = getattr(content, "type", None)
if content_type == "usage":
details = getattr(content, "details", None)
if details:
usage_data = {
"total_tokens": getattr(details, "total_token_count", 0) or 0,
"prompt_tokens": getattr(details, "input_token_count", 0) or 0,
"completion_tokens": getattr(details, "output_token_count", 0) or 0,
}
break
ui_message = {
"id": af_msg.message_id or f"restored-{i}",
"role": role,
@@ -160,6 +174,10 @@ class AgentFrameworkExecutor:
"message_id": af_msg.message_id,
}
# Add usage data if available
if usage_data:
ui_message["usage"] = usage_data
ui_messages.append(ui_message)
logger.info(f"Restored {len(ui_messages)} display messages for thread {thread_id}")
@@ -697,6 +715,8 @@ class AgentFrameworkExecutor:
Parsed input for workflow
"""
try:
from ._utils import parse_input_for_type
# Get the start executor and its input type
start_executor, message_types = self._get_start_executor_message_types(workflow)
if not start_executor:
@@ -713,45 +733,12 @@ class AgentFrameworkExecutor:
logger.debug("Could not select primary input type for workflow - using raw dict")
return input_data
# If input type is dict, return as-is
if input_type is dict:
return input_data
# Handle primitive types
if input_type in (str, int, float, bool):
try:
if isinstance(input_data, input_type):
return input_data
if "input" in input_data:
return input_type(input_data["input"])
if len(input_data) == 1:
value = next(iter(input_data.values()))
return input_type(value)
return input_data
except (ValueError, TypeError) as e:
logger.warning(f"Failed to convert input to {input_type}: {e}")
return input_data
# If it's a Pydantic model, validate and create instance
if hasattr(input_type, "model_validate"):
try:
return input_type.model_validate(input_data)
except Exception as e:
logger.warning(f"Failed to validate input as {input_type}: {e}")
return input_data
# If it's a dataclass or other type with annotations
elif hasattr(input_type, "__annotations__"):
try:
return input_type(**input_data)
except Exception as e:
logger.warning(f"Failed to create {input_type} from input data: {e}")
return input_data
# Use consolidated parsing logic from _utils
return parse_input_for_type(input_data, input_type)
except Exception as e:
logger.warning(f"Error parsing structured workflow input: {e}")
return input_data
return input_data
def _parse_raw_workflow_input(self, workflow: Any, raw_input: str) -> Any:
"""Parse raw input string based on workflow's expected input type.
@@ -764,6 +751,8 @@ class AgentFrameworkExecutor:
Parsed input for workflow
"""
try:
from ._utils import parse_input_for_type
# Get the start executor and its input type
start_executor, message_types = self._get_start_executor_message_types(workflow)
if not start_executor:
@@ -780,43 +769,9 @@ class AgentFrameworkExecutor:
logger.debug("Could not select primary input type for workflow - using raw string")
return raw_input
# If input type is str, return as-is
if input_type is str:
return raw_input
# If it's a Pydantic model, try to parse JSON
if hasattr(input_type, "model_validate_json"):
try:
# First try to parse as JSON
if raw_input.strip().startswith("{"):
return input_type.model_validate_json(raw_input)
# Try common field names
common_fields = ["message", "text", "input", "data", "content"]
for field in common_fields:
try:
return input_type(**{field: raw_input})
except Exception as e:
logger.debug(f"Failed to parse input using field '{field}': {e}")
continue
# Last resort: try default constructor
return input_type()
except Exception as e:
logger.debug(f"Failed to parse input as {input_type}: {e}")
# If it's a dataclass, try JSON parsing
elif hasattr(input_type, "__annotations__"):
try:
if raw_input.strip().startswith("{"):
parsed = json.loads(raw_input)
return input_type(**parsed)
except Exception as e:
logger.debug(f"Failed to parse input as {input_type}: {e}")
# Use consolidated parsing logic from _utils
return parse_input_for_type(raw_input, input_type)
except Exception as e:
logger.debug(f"Error determining workflow input type: {e}")
# Fallback: return raw string
return raw_input
logger.debug(f"Error parsing workflow input: {e}")
return raw_input
@@ -6,7 +6,6 @@ import json
import logging
import uuid
from collections.abc import Sequence
from dataclasses import asdict, is_dataclass
from datetime import datetime
from typing import Any, Union
@@ -97,8 +96,18 @@ class MessageMapper:
# Import Agent Framework types for proper isinstance checks
try:
from agent_framework import AgentRunResponseUpdate, WorkflowEvent
from agent_framework._workflows._events import AgentRunUpdateEvent
# Handle agent updates (AgentRunResponseUpdate)
# Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate
# This must be checked BEFORE generic WorkflowEvent check
if isinstance(raw_event, AgentRunUpdateEvent):
# Extract the AgentRunResponseUpdate from the event's data attribute
if raw_event.data and isinstance(raw_event.data, AgentRunResponseUpdate):
return await self._convert_agent_update(raw_event.data, context)
# If no data, treat as generic workflow event
return await self._convert_workflow_event(raw_event, context)
# Handle agent updates (AgentRunResponseUpdate) - for direct agent execution
if isinstance(raw_event, AgentRunResponseUpdate):
return await self._convert_agent_update(raw_event, context)
@@ -258,13 +267,22 @@ class MessageMapper:
List of OpenAI response stream events
"""
try:
serialized_payload = self._serialize_payload(getattr(event, "data", None))
# Get event data and serialize if it's a SerializationMixin
event_data = getattr(event, "data", None)
if event_data is not None and hasattr(event_data, "to_dict"):
# SerializationMixin objects - convert to dict for JSON serialization
try:
event_data = event_data.to_dict()
except Exception as e:
logger.debug(f"Failed to serialize event data with to_dict(): {e}")
event_data = str(event_data)
# Create structured workflow event
workflow_event = ResponseWorkflowEventComplete(
type="response.workflow_event.complete",
data={
"event_type": event.__class__.__name__,
"data": serialized_payload,
"data": event_data,
"executor_id": getattr(event, "executor_id", None),
"timestamp": datetime.now().isoformat(),
},
@@ -280,59 +298,6 @@ class MessageMapper:
logger.warning(f"Error converting workflow event: {e}")
return [await self._create_error_event(str(e), context)]
def _serialize_payload(self, value: Any) -> Any:
"""Best-effort JSON serialization for workflow payloads."""
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple, set)):
return [self._serialize_payload(item) for item in value]
if isinstance(value, dict):
return {str(k): self._serialize_payload(v) for k, v in value.items()}
if is_dataclass(value) and not isinstance(value, type):
try:
return self._serialize_payload(asdict(value))
except Exception as exc:
logger.debug("Failed to serialize dataclass payload: %s", exc)
model_dump_method = getattr(value, "model_dump", None)
if model_dump_method is not None and callable(model_dump_method):
try:
dumped = model_dump_method()
return self._serialize_payload(dumped)
except Exception as exc:
logger.debug("Failed to serialize payload via model_dump: %s", exc)
dict_method = getattr(value, "dict", None)
if dict_method is not None and callable(dict_method):
try:
dict_result = dict_method()
return self._serialize_payload(dict_result)
except Exception as exc:
logger.debug("Failed to serialize payload via dict(): %s", exc)
to_dict_method = getattr(value, "to_dict", None)
if to_dict_method is not None and callable(to_dict_method):
try:
to_dict_result = to_dict_method()
return self._serialize_payload(to_dict_result)
except Exception as exc:
logger.debug("Failed to serialize payload via to_dict(): %s", exc)
model_dump_json_method = getattr(value, "model_dump_json", None)
if model_dump_json_method is not None and callable(model_dump_json_method):
try:
json_str = model_dump_json_method()
if isinstance(json_str, (str, bytes, bytearray)):
return json.loads(json_str)
except Exception as exc:
logger.debug("Failed to serialize payload via model_dump_json: %s", exc)
if hasattr(value, "__dict__"):
try:
return self._serialize_payload({
key: self._serialize_payload(val) for key, val in value.__dict__.items() if not key.startswith("_")
})
except Exception as exc:
logger.debug("Failed to serialize payload via __dict__: %s", exc)
return str(value)
# Content type mappers - implementing our comprehensive mapping plan
async def _map_text_content(self, content: Any, context: dict[str, Any]) -> ResponseTextDeltaEvent:
@@ -409,13 +374,24 @@ class MessageMapper:
context["usage_data"] = []
context["usage_data"].append(content)
# Extract usage from UsageContent.details (UsageDetails object)
details = getattr(content, "details", None)
total_tokens = 0
prompt_tokens = 0
completion_tokens = 0
if details:
total_tokens = getattr(details, "total_token_count", 0) or 0
prompt_tokens = getattr(details, "input_token_count", 0) or 0
completion_tokens = getattr(details, "output_token_count", 0) or 0
return ResponseUsageEventComplete(
type="response.usage.complete",
data={
"usage_data": getattr(content, "usage_data", {}),
"total_tokens": getattr(content, "total_tokens", 0),
"completion_tokens": getattr(content, "completion_tokens", 0),
"prompt_tokens": getattr(content, "prompt_tokens", 0),
"usage_data": details.to_dict() if details and hasattr(details, "to_dict") else {},
"total_tokens": total_tokens,
"completion_tokens": completion_tokens,
"prompt_tokens": prompt_tokens,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
@@ -263,6 +263,8 @@ class DevServer:
start_executor_id = ""
try:
from ._utils import generate_input_schema
start_executor = entity_obj.get_start_executor()
except Exception as e:
logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
@@ -278,17 +280,8 @@ class DevServer:
if input_type:
input_type_name = getattr(input_type, "__name__", str(input_type))
if input_type is str:
input_schema = {"type": "string"}
elif input_type is dict:
input_schema = {"type": "object"}
elif hasattr(input_type, "model_json_schema"):
try:
input_schema = input_type.model_json_schema()
except Exception as exc: # pragma: no cover - defensive path
logger.debug(f"model_json_schema() failed for workflow {entity_id}: {exc}")
elif hasattr(input_type, "__annotations__"):
input_schema = {"type": "object"}
# Generate schema using comprehensive schema generation
input_schema = generate_input_schema(input_type)
if not input_schema:
input_schema = {"type": "string"}
@@ -0,0 +1,421 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility functions for DevUI."""
import inspect
import json
import logging
from dataclasses import fields, is_dataclass
from typing import Any, get_args, get_origin
logger = logging.getLogger(__name__)
# ============================================================================
# Type System Utilities
# ============================================================================
def is_serialization_mixin(cls: type) -> bool:
"""Check if class is a SerializationMixin subclass.
Args:
cls: Class to check
Returns:
True if class is a SerializationMixin subclass
"""
try:
from agent_framework._serialization import SerializationMixin
return isinstance(cls, type) and issubclass(cls, SerializationMixin)
except ImportError:
return False
def _type_to_schema(type_hint: Any, field_name: str) -> dict[str, Any]:
"""Convert a type hint to JSON schema.
Args:
type_hint: Type hint to convert
field_name: Name of the field (for documentation)
Returns:
JSON schema dict
"""
type_str = str(type_hint)
# Handle None/Optional
if type_hint is type(None):
return {"type": "null"}
# Handle basic types
if type_hint is str or "str" in type_str:
return {"type": "string"}
if type_hint is int or "int" in type_str:
return {"type": "integer"}
if type_hint is float or "float" in type_str:
return {"type": "number"}
if type_hint is bool or "bool" in type_str:
return {"type": "boolean"}
# Handle Literal types (for enum-like values)
if "Literal" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
if args:
return {"type": "string", "enum": list(args)}
# Handle Union/Optional
if "Union" in type_str or "Optional" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
# Filter out None type
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
return _type_to_schema(non_none_args[0], field_name)
# Multiple types - pick first non-None
if non_none_args:
return _type_to_schema(non_none_args[0], field_name)
# Handle collections
if "list" in type_str or "List" in type_str or "Sequence" in type_str:
origin = get_origin(type_hint)
if origin is not None:
args = get_args(type_hint)
if args:
items_schema = _type_to_schema(args[0], field_name)
return {"type": "array", "items": items_schema}
return {"type": "array"}
if "dict" in type_str or "Dict" in type_str or "Mapping" in type_str:
return {"type": "object"}
# Default fallback
return {"type": "string", "description": f"Type: {type_hint}"}
def generate_schema_from_serialization_mixin(cls: type[Any]) -> dict[str, Any]:
"""Generate JSON schema from SerializationMixin class.
Introspects the __init__ signature to extract parameter types and defaults.
Args:
cls: SerializationMixin subclass
Returns:
JSON schema dict
"""
sig = inspect.signature(cls)
# Get type hints
try:
from typing import get_type_hints
type_hints = get_type_hints(cls)
except Exception:
type_hints = {}
properties: dict[str, Any] = {}
required: list[str] = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "kwargs"):
continue
# Get type annotation
param_type = type_hints.get(param_name, str)
# Generate schema for this parameter
param_schema = _type_to_schema(param_type, param_name)
properties[param_name] = param_schema
# Check if required (no default value, not VAR_KEYWORD)
if param.default == inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
required.append(param_name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return schema
def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
"""Generate JSON schema from dataclass.
Args:
cls: Dataclass type
Returns:
JSON schema dict
"""
if not is_dataclass(cls):
return {"type": "object"}
properties: dict[str, Any] = {}
required: list[str] = []
for field in fields(cls):
# Generate schema for field type
field_schema = _type_to_schema(field.type, field.name)
properties[field.name] = field_schema
# Check if required (no default value)
if field.default == field.default_factory: # No default
required.append(field.name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return schema
def generate_input_schema(input_type: type) -> dict[str, Any]:
"""Generate JSON schema for workflow input type.
Supports multiple input types in priority order:
1. Built-in types (str, dict, int, etc.)
2. Pydantic models (via model_json_schema)
3. SerializationMixin classes (via __init__ introspection)
4. Dataclasses (via fields introspection)
5. Fallback to string
Args:
input_type: Input type to generate schema for
Returns:
JSON schema dict
"""
# 1. Built-in types
if input_type is str:
return {"type": "string"}
if input_type is dict:
return {"type": "object"}
if input_type is int:
return {"type": "integer"}
if input_type is float:
return {"type": "number"}
if input_type is bool:
return {"type": "boolean"}
# 2. Pydantic models (legacy support)
if hasattr(input_type, "model_json_schema"):
return input_type.model_json_schema() # type: ignore
# 3. SerializationMixin classes (ChatMessage, etc.)
if is_serialization_mixin(input_type):
return generate_schema_from_serialization_mixin(input_type)
# 4. Dataclasses
if is_dataclass(input_type):
return generate_schema_from_dataclass(input_type)
# 5. Fallback to string
type_name = getattr(input_type, "__name__", str(input_type))
return {"type": "string", "description": f"Input type: {type_name}"}
# ============================================================================
# Input Parsing Utilities
# ============================================================================
def parse_input_for_type(input_data: Any, target_type: type) -> Any:
"""Parse input data to match the target type.
Handles conversion from raw input (string, dict) to the expected type:
- Built-in types: direct conversion
- Pydantic models: use model_validate or model_validate_json
- SerializationMixin: use from_dict or construct from string
- Dataclasses: construct from dict
Args:
input_data: Raw input data (string, dict, or already correct type)
target_type: Expected type for the input
Returns:
Parsed input matching target_type, or original input if parsing fails
"""
# If already correct type, return as-is
if isinstance(input_data, target_type):
return input_data
# Handle string input
if isinstance(input_data, str):
return _parse_string_input(input_data, target_type)
# Handle dict input
if isinstance(input_data, dict):
return _parse_dict_input(input_data, target_type)
# Fallback: return original
return input_data
def _parse_string_input(input_str: str, target_type: type) -> Any:
"""Parse string input to target type.
Args:
input_str: Input string
target_type: Target type
Returns:
Parsed input or original string
"""
# Built-in types
if target_type is str:
return input_str
if target_type is int:
try:
return int(input_str)
except ValueError:
return input_str
elif target_type is float:
try:
return float(input_str)
except ValueError:
return input_str
elif target_type is bool:
return input_str.lower() in ("true", "1", "yes")
# Pydantic models
if hasattr(target_type, "model_validate_json"):
try:
# Try parsing as JSON first
if input_str.strip().startswith("{"):
return target_type.model_validate_json(input_str) # type: ignore
# Try common field names with the string value
common_fields = ["text", "message", "content", "input", "data"]
for field in common_fields:
try:
return target_type(**{field: input_str}) # type: ignore
except Exception as e:
logger.debug(f"Failed to parse string input with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as Pydantic model: {e}")
# SerializationMixin (like ChatMessage)
if is_serialization_mixin(target_type):
try:
# Try parsing as JSON dict first
if input_str.strip().startswith("{"):
data = json.loads(input_str)
if hasattr(target_type, "from_dict"):
return target_type.from_dict(data) # type: ignore
return target_type(**data) # type: ignore
# For ChatMessage specifically: create from text
# Try common field patterns
common_fields = ["text", "message", "content"]
sig = inspect.signature(target_type)
params = list(sig.parameters.keys())
# If it has 'text' param, use it
if "text" in params:
try:
return target_type(role="user", text=input_str) # type: ignore
except Exception as e:
logger.debug(f"Failed to create SerializationMixin with text field: {e}")
# Try other common fields
for field in common_fields:
if field in params:
try:
return target_type(**{field: input_str}) # type: ignore
except Exception as e:
logger.debug(f"Failed to create SerializationMixin with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as SerializationMixin: {e}")
# Dataclasses
if is_dataclass(target_type):
try:
# Try parsing as JSON
if input_str.strip().startswith("{"):
data = json.loads(input_str)
return target_type(**data) # type: ignore
# Try common field names
common_fields = ["text", "message", "content", "input", "data"]
for field in common_fields:
try:
return target_type(**{field: input_str}) # type: ignore
except Exception as e:
logger.debug(f"Failed to create dataclass with field '{field}': {e}")
continue
except Exception as e:
logger.debug(f"Failed to parse string as dataclass: {e}")
# Fallback: return original string
return input_str
def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
"""Parse dict input to target type.
Args:
input_dict: Input dictionary
target_type: Target type
Returns:
Parsed input or original dict
"""
# Handle primitive types - extract from common field names
if target_type in (str, int, float, bool):
try:
# If it's already the right type, return as-is
if isinstance(input_dict, target_type):
return input_dict
# Try "input" field first (common for workflow inputs)
if "input" in input_dict:
return target_type(input_dict["input"]) # type: ignore
# If single-key dict, extract the value
if len(input_dict) == 1:
value = next(iter(input_dict.values()))
return target_type(value) # type: ignore
# Otherwise, return as-is
return input_dict
except (ValueError, TypeError) as e:
logger.debug(f"Failed to convert dict to {target_type}: {e}")
return input_dict
# If target is dict, return as-is
if target_type is dict:
return input_dict
# Pydantic models
if hasattr(target_type, "model_validate"):
try:
return target_type.model_validate(input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
# SerializationMixin
if is_serialization_mixin(target_type):
try:
if hasattr(target_type, "from_dict"):
return target_type.from_dict(input_dict) # type: ignore
return target_type(**input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
# Dataclasses
if is_dataclass(target_type):
try:
return target_type(**input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to parse dict as dataclass: {e}")
# Fallback: return original dict
return input_dict
@@ -37,6 +37,13 @@ class EntityInfo(BaseModel):
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
# Agent-specific fields (optional, populated when available)
instructions: str | None = None
model: str | None = None
chat_client_type: str | None = None
context_providers: list[str] | None = None
middleware: list[str] | None = None
# Workflow-specific fields (populated only for detailed info requests)
executors: list[str] | None = None
workflow_dump: dict[str, Any] | None = None
@@ -0,0 +1,33 @@
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_510_1294)">
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
</g>
<defs>
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.39647" dy="8.39647"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="50"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
</filter>
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#D59FFF"/>
<stop offset="1" stop-color="#8562C5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,11 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="/assets/index-DPEaaIdK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1AmQWga.css">
<script type="module" crossorigin src="/assets/index-D0SfShuZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WsCIE0bH.css">
</head>
<body>
<div id="root"></div>
+21
View File
@@ -102,6 +102,27 @@ Messages and events from agents/workflows are mapped to OpenAI response types in
| `FunctionApprovalResponseContent` | Custom event | Custom |
| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom |
## Frontend Development
To build the frontend:
```bash
cd frontend
yarn install
# Create .env.local with backend URL
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
# Create .env.production (empty for relative URLs)
echo '' > .env.production
# Development
yarn dev
# Build (copies to backend)
yarn build
```
## Troubleshooting
- **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI.
+17 -5
View File
@@ -1,11 +1,23 @@
# React + TypeScript + Vite
# DevUI Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Build Instructions
Currently, two official plugins are available:
```bash
cd frontend
yarn install
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
# Create .env.local with backend URL
echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local
# Create .env.production (empty for relative URLs)
echo '' > .env.production
# Development
yarn dev
# Build (copies to backend)
yarn build
```
## Expanding the ESLint configuration
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
</head>
@@ -0,0 +1,33 @@
<svg width="805" height="805" viewBox="0 0 805 805" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_510_1294)">
<path d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z" fill="url(#paint0_linear_510_1294)"/>
</g>
<defs>
<filter id="filter0_iii_510_1294" x="103.759" y="93.4694" width="578.735" height="599.314" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.39647" dy="8.39647"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.835294 0 0 0 0 0.623529 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="20.9912"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.3 0"/>
<feBlend mode="plus-darker" in2="effect1_innerShadow_510_1294" result="effect2_innerShadow_510_1294"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-26.2432" dy="-26.2432"/>
<feGaussianBlur stdDeviation="50"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368627 0 0 0 0 0.262745 0 0 0 0 0.564706 0 0 0 0.1 0"/>
<feBlend mode="plus-darker" in2="effect2_innerShadow_510_1294" result="effect3_innerShadow_510_1294"/>
</filter>
<linearGradient id="paint0_linear_510_1294" x1="255.628" y1="-34.3245" x2="618.483" y2="632.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#D59FFF"/>
<stop offset="1" stop-color="#8562C5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

+199 -120
View File
@@ -4,7 +4,6 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { AppHeader } from "@/components/shared/app-header";
import { DebugPanel } from "@/components/shared/debug-panel";
import { SettingsModal } from "@/components/shared/settings-modal";
@@ -12,8 +11,9 @@ import { GalleryView } from "@/components/gallery";
import { AgentView } from "@/components/agent/agent-view";
import { WorkflowView } from "@/components/workflow/workflow-view";
import { LoadingState } from "@/components/ui/loading-state";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import type {
AgentInfo,
@@ -21,6 +21,7 @@ import type {
AppState,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
export default function App() {
const [appState, setAppState] = useState<AppState>({
@@ -29,8 +30,13 @@ export default function App() {
isLoading: true,
});
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [showDebugPanel, setShowDebugPanel] = useState(() => {
const saved = localStorage.getItem("showDebugPanel");
return saved !== null ? saved === "true" : true;
});
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
const savedWidth = localStorage.getItem("debugPanelWidth");
return savedWidth ? parseInt(savedWidth, 10) : 320;
@@ -41,6 +47,7 @@ export default function App() {
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
// Initialize app - load agents and workflows
useEffect(() => {
@@ -51,16 +58,51 @@ export default function App() {
apiClient.getWorkflows(),
]);
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent:
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedAgent =
agents.find((a) => a.id === entityId) ||
workflows.find((w) => w.id === entityId);
// If entity not found but was requested, show notification
if (!selectedAgent) {
setShowEntityNotFoundToast(true);
}
}
// Fallback to first available entity if URL entity not found
if (!selectedAgent) {
selectedAgent =
agents.length > 0
? agents[0]
: workflows.length > 0
? workflows[0]
: undefined,
: undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedAgent) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedAgent.id);
window.history.replaceState({}, "", url);
} else {
// Clear entity_id if no entities available
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.replaceState({}, "", url);
}
}
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent,
isLoading: false,
}));
} catch (error) {
@@ -76,7 +118,11 @@ export default function App() {
loadData();
}, []);
// Save debug panel width to localStorage
// Save debug panel state to localStorage
useEffect(() => {
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
}, [showDebugPanel]);
useEffect(() => {
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
}, [debugPanelWidth]);
@@ -111,11 +157,6 @@ export default function App() {
[debugPanelWidth]
);
// Handle double-click to collapse
const handleDoubleClick = useCallback(() => {
setDebugPanelOpen(false);
}, []);
// Handle entity selection
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
setAppState((prev) => ({
@@ -124,18 +165,26 @@ export default function App() {
currentThread: undefined,
}));
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", item.id);
window.history.pushState({}, "", url);
// Clear debug events when switching entities
setDebugEvents([]);
}, []);
// Handle debug events from active view
const handleDebugEvent = useCallback((event: ExtendedResponseStreamEvent | 'clear') => {
if (event === 'clear') {
setDebugEvents([]);
} else {
setDebugEvents((prev) => [...prev, event]);
}
}, []);
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
setDebugEvents([]);
} else {
setDebugEvents((prev) => [...prev, event]);
}
},
[]
);
// Handle adding sample entity
const handleAddSample = useCallback(async (sample: SampleEntity) => {
@@ -146,9 +195,9 @@ export default function App() {
try {
// Call backend to fetch and add entity
const newEntity = await apiClient.addEntity(sample.url, {
source: 'remote_gallery',
source: "remote_gallery",
originalUrl: sample.url,
sampleId: sample.id
sampleId: sample.id,
});
// Convert backend entity to frontend format
@@ -157,52 +206,67 @@ export default function App() {
name: newEntity.name,
description: newEntity.description,
type: newEntity.type,
source: (newEntity.source as "directory" | "in_memory" | "remote_gallery") || 'remote_gallery',
source:
(newEntity.source as "directory" | "in_memory" | "remote_gallery") ||
"remote_gallery",
has_env: false,
module_path: undefined
module_path: undefined,
};
// Update app state
if (newEntity.type === 'agent') {
if (newEntity.type === "agent") {
const agentEntity = {
...convertedEntity,
tools: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
)
tools: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
} as AgentInfo;
setAppState(prev => ({
setAppState((prev) => ({
...prev,
agents: [...prev.agents, agentEntity],
selectedAgent: agentEntity
selectedAgent: agentEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", agentEntity.id);
window.history.pushState({}, "", url);
} else {
const workflowEntity = {
...convertedEntity,
executors: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
executors: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
input_schema: { type: "string" },
input_type_name: "Input",
start_executor_id: (newEntity.tools && newEntity.tools.length > 0)
? (typeof newEntity.tools[0] === 'string' ? newEntity.tools[0] : JSON.stringify(newEntity.tools[0]))
: "unknown"
start_executor_id:
newEntity.tools && newEntity.tools.length > 0
? typeof newEntity.tools[0] === "string"
? newEntity.tools[0]
: JSON.stringify(newEntity.tools[0])
: "unknown",
} as WorkflowInfo;
setAppState(prev => ({
setAppState((prev) => ({
...prev,
workflows: [...prev.workflows, workflowEntity],
selectedAgent: workflowEntity
selectedAgent: workflowEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", workflowEntity.id);
window.history.pushState({}, "", url);
}
// Close gallery and clear debug events
setShowGallery(false);
setDebugEvents([]);
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Failed to add sample entity';
console.error('Failed to add sample entity:', errMsg);
const errMsg =
error instanceof Error ? error.message : "Failed to add sample entity";
console.error("Failed to add sample entity:", errMsg);
setErrorEntityId(sample.id);
setErrorMessage(errMsg);
} finally {
@@ -216,29 +280,35 @@ export default function App() {
}, []);
// Handle removing entity
const handleRemoveEntity = useCallback(async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
const handleRemoveEntity = useCallback(
async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
// Update app state
setAppState(prev => ({
...prev,
agents: prev.agents.filter(a => a.id !== entityId),
workflows: prev.workflows.filter(w => w.id !== entityId),
selectedAgent: prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent
}));
// Update app state
setAppState((prev) => ({
...prev,
agents: prev.agents.filter((a) => a.id !== entityId),
workflows: prev.workflows.filter((w) => w.id !== entityId),
selectedAgent:
prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent,
}));
// Clear debug events if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
setDebugEvents([]);
// Update URL - clear entity_id if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.pushState({}, "", url);
setDebugEvents([]);
}
} catch (error) {
console.error("Failed to remove entity:", error);
}
} catch (error) {
console.error('Failed to remove entity:', error);
}
}, [appState.selectedAgent?.id]);
},
[appState.selectedAgent?.id]
);
// Show loading state while initializing
if (appState.isLoading) {
@@ -293,24 +363,29 @@ export default function App() {
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.
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>
<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>
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">http://localhost:8080</span>
Default:{" "}
<span className="font-mono">http://localhost:8080</span>
</p>
</div>
@@ -339,10 +414,7 @@ export default function App() {
</div>
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
</div>
);
}
@@ -373,7 +445,9 @@ export default function App() {
errorMessage={errorMessage}
onClearError={handleClearError}
onClose={() => setShowGallery(false)}
hasExistingEntities={appState.agents.length > 0 || appState.workflows.length > 0}
hasExistingEntities={
appState.agents.length > 0 || appState.workflows.length > 0
}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
@@ -409,51 +483,50 @@ export default function App() {
)}
</div>
{/* Resize Handle */}
{debugPanelOpen && (
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
>
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
<div
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
)}
{showDebugPanel ? (
<>
{/* Resize Handle */}
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
}`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-y-0 -left-2 -right-2 flex items-center justify-center">
<div
className={`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${
isResizing
? "bg-primary shadow-lg shadow-primary/25"
: "bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20"
}`}
></div>
</div>
</div>
{/* Button to reopen when closed */}
{!debugPanelOpen && (
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setDebugPanelOpen(true)}
className="h-full w-8 rounded-none border-l"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
{/* Right Panel - Debug */}
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
</div>
</>
) : (
/* Button to reopen when closed */
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDebugPanel(true)}
className="h-full w-10 rounded-none border-l"
title="Show debug panel"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
)}
</>
@@ -461,10 +534,16 @@ export default function App() {
</div>
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
message="Entity not found. Showing first available entity instead."
type="info"
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
</div>
);
}
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AgentDetailsModal } from "@/components/shared/agent-details-modal";
import {
SendHorizontal,
User,
@@ -28,14 +29,9 @@ import {
Plus,
AlertCircle,
Paperclip,
Info,
Trash2,
FileText,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -111,8 +107,33 @@ function MessageBubble({ message }: MessageBubbleProps) {
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{new Date(message.timestamp).toLocaleTimeString()}
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{new Date(message.timestamp).toLocaleTimeString()}</span>
{!isUser && message.usage && (
<>
<span></span>
<span className="text-[11px]">
{message.usage.total_tokens >= 1000
? `${(message.usage.total_tokens / 1000).toFixed(2)}k`
: message.usage.total_tokens}{" "}
tokens
{message.usage.prompt_tokens > 0 && (
<span className="opacity-70">
{" "}
(
{message.usage.prompt_tokens >= 1000
? `${(message.usage.prompt_tokens / 1000).toFixed(1)}k`
: message.usage.prompt_tokens}{" "}
in,{" "}
{message.usage.completion_tokens >= 1000
? `${(message.usage.completion_tokens / 1000).toFixed(1)}k`
: message.usage.completion_tokens}{" "}
out)
</span>
)}
</span>
</>
)}
</div>
</div>
</div>
@@ -154,12 +175,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [threadUsage, setThreadUsage] = useState<{
total_tokens: number;
message_count: number;
}>({ total_tokens: 0, message_count: 0 });
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const currentMessageUsage = useRef<{
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
} | null>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
@@ -439,12 +469,78 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
messages: [],
isStreaming: false,
});
setThreadUsage({ total_tokens: 0, message_count: 0 });
accumulatedText.current = "";
} catch (error) {
console.error("Failed to create thread:", error);
}
}, [selectedAgent]);
// Handle thread deletion
const handleDeleteThread = useCallback(
async (threadId: string, e?: React.MouseEvent) => {
// Prevent event from bubbling to SelectItem
if (e) {
e.preventDefault();
e.stopPropagation();
}
// Confirm deletion
if (!confirm("Delete this thread? This cannot be undone.")) {
return;
}
try {
const success = await apiClient.deleteThread(threadId);
if (success) {
// Remove thread from available threads
const updatedThreads = availableThreads.filter((t) => t.id !== threadId);
setAvailableThreads(updatedThreads);
// If deleted thread was selected, switch to another thread or clear chat
if (currentThread?.id === threadId) {
if (updatedThreads.length > 0) {
// Select the most recent remaining thread
const nextThread = updatedThreads[0];
setCurrentThread(nextThread);
// Load messages for the next thread
try {
const threadMessages = await apiClient.getThreadMessages(nextThread.id);
setChatState({
messages: threadMessages,
isStreaming: false,
});
} catch (error) {
console.error("Failed to load thread messages:", error);
setChatState({
messages: [],
isStreaming: false,
});
}
} else {
// No threads left, clear everything
setCurrentThread(undefined);
setChatState({
messages: [],
isStreaming: false,
});
setThreadUsage({ total_tokens: 0, message_count: 0 });
accumulatedText.current = "";
}
}
// Clear debug panel
onDebugEvent("clear");
}
} catch (error) {
console.error("Failed to delete thread:", error);
alert("Failed to delete thread. Please try again.");
}
},
[availableThreads, currentThread, onDebugEvent]
);
// Handle thread selection
const handleThreadSelect = useCallback(
async (threadId: string) => {
@@ -465,6 +561,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
isStreaming: false,
});
// Calculate cumulative usage for this thread
const totalTokens = threadMessages.reduce(
(sum, msg) => sum + (msg.usage?.total_tokens || 0),
0
);
const messageCount = threadMessages.filter(
(msg) => msg.role === "assistant" && msg.usage
).length;
setThreadUsage({ total_tokens: totalTokens, message_count: messageCount });
console.log(
`Restored ${threadMessages.length} messages for thread ${threadId}`
);
@@ -602,6 +708,20 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Pass all events to debug panel
onDebugEvent(openAIEvent);
// Handle usage events
if (openAIEvent.type === "response.usage.complete") {
const usageEvent = openAIEvent as import("@/types").ResponseUsageEventComplete;
console.log("📊 Usage event received:", usageEvent.data);
if (usageEvent.data) {
currentMessageUsage.current = {
total_tokens: usageEvent.data.total_tokens || 0,
prompt_tokens: usageEvent.data.prompt_tokens || 0,
completion_tokens: usageEvent.data.completion_tokens || 0,
};
console.log("📊 Set usage:", currentMessageUsage.current);
}
}
// Handle error events from the stream
if (openAIEvent.type === "error") {
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & {
@@ -663,14 +783,35 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// (Server will close the stream when done, so we'll exit the loop naturally)
}
// Stream ended - mark as complete
// Stream ended - mark as complete and attach usage
const finalUsage = currentMessageUsage.current;
console.log("📊 Stream ended, attaching usage to message:", finalUsage);
setChatState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id ? { ...msg, streaming: false } : msg
msg.id === assistantMessage.id
? {
...msg,
streaming: false,
usage: finalUsage || undefined,
}
: msg
),
}));
// Update thread-level usage stats
if (finalUsage) {
setThreadUsage((prev) => ({
total_tokens: prev.total_tokens + finalUsage.total_tokens,
message_count: prev.message_count + 1,
}));
console.log("📊 Updated thread usage");
}
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
console.error("Streaming error:", error);
setChatState((prev) => ({
@@ -831,14 +972,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
<Info className="h-4 w-4" />
</Button>
</div>
@@ -849,7 +987,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-full sm:w-48">
<SelectTrigger className="w-full sm:w-64">
<SelectValue
placeholder={
loadingThreads
@@ -860,7 +998,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
? `Thread ${currentThread.id.slice(-8)}`
: "Select thread"
}
/>
>
{currentThread && (
<div className="flex items-center gap-2 text-xs">
<span>Thread {currentThread.id.slice(-8)}</span>
{threadUsage.total_tokens > 0 && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{threadUsage.total_tokens >= 1000
? `${(threadUsage.total_tokens / 1000).toFixed(1)}k`
: threadUsage.total_tokens}{" "}
tokens
</span>
</>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableThreads.map((thread) => (
@@ -878,6 +1033,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => currentThread && handleDeleteThread(currentThread.id)}
disabled={!currentThread || isSubmitting}
title={currentThread ? `Delete Thread ${currentThread.id.slice(-8)}` : "No thread selected"}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="lg"
@@ -896,68 +1061,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{selectedAgent.description}
</p>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Tools */}
<div className="flex items-center gap-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Tools:</span>
<span className="font-mono">
{selectedAgent.tools.length > 0
? selectedAgent.tools.join(", ")
: "No tools"}
</span>
<span className="text-muted-foreground">
({selectedAgent.tools.length})
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedAgent.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedAgent.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedAgent.source === "directory"
? "Local"
: selectedAgent.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedAgent.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedAgent.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedAgent.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedAgent.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Messages */}
@@ -1077,6 +1180,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</form>
</div>
</div>
{/* Agent Details Modal */}
<AgentDetailsModal
agent={selectedAgent}
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>
</div>
);
}
@@ -0,0 +1,219 @@
/**
* AgentDetailsModal - Responsive grid-based modal for displaying agent metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Bot,
Package,
FileText,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import type { AgentInfo } from "@/types";
interface AgentDetailsModalProps {
agent: AgentInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function AgentDetailsModal({
agent,
open,
onOpenChange,
}: AgentDetailsModalProps) {
const sourceIcon =
agent.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : agent.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
agent.source === "directory"
? "Local"
: agent.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Agent Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<Bot className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{agent.name || agent.id}
</h2>
</div>
{agent.description && (
<p className="text-muted-foreground">{agent.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Model & Client */}
{(agent.model || agent.chat_client_type) && (
<DetailCard
title="Model & Client"
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
>
<div className="space-y-1">
{agent.model && (
<div className="font-mono text-foreground">{agent.model}</div>
)}
{agent.chat_client_type && (
<div className="text-xs">({agent.chat_client_type})</div>
)}
</div>
</DetailCard>
)}
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{agent.module_path && (
<div className="font-mono text-xs break-all">
{agent.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
agent.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
}
>
{agent.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Full Width Sections */}
{agent.instructions && (
<DetailCard
title="Instructions"
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
className="mb-4"
>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{agent.instructions}
</div>
</DetailCard>
)}
{/* Tools and Middleware Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tools */}
<DetailCard
title={`Tools (${agent.tools.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{agent.tools.length > 0 ? (
<ul className="space-y-1">
{agent.tools.map((tool, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{tool}
</li>
))}
</ul>
) : (
<div className="text-muted-foreground">No tools configured</div>
)}
</DetailCard>
{/* Middleware */}
{agent.middleware && agent.middleware.length > 0 && (
<DetailCard
title={`Middleware (${agent.middleware.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
<ul className="space-y-1">
{agent.middleware.map((mw, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{mw}
</li>
))}
</ul>
</DetailCard>
)}
{/* Context Providers */}
{agent.context_providers && agent.context_providers.length > 0 && (
<DetailCard
title={`Context Providers (${agent.context_providers.length})`}
icon={<Database className="h-4 w-4 text-muted-foreground" />}
className={!agent.middleware || agent.middleware.length === 0 ? "md:col-start-2" : ""}
>
<ul className="space-y-1">
{agent.context_providers.map((cp, index) => (
<li key={index} className="font-mono text-xs text-foreground">
{cp}
</li>
))}
</ul>
</DetailCard>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,7 +32,35 @@ export function AppHeader({
}: AppHeaderProps) {
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="font-semibold">Dev UI</div>
<div className="flex items-center gap-2 font-semibold">
<svg
width="24"
height="24"
viewBox="0 0 805 805"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0"
>
<path
d="M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z"
fill="url(#paint0_linear_510_1294)"
/>
<defs>
<linearGradient
id="paint0_linear_510_1294"
x1="255.628"
y1="-34.3245"
x2="618.483"
y2="632.032"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D59FFF" />
<stop offset="1" stopColor="#8562C5" />
</linearGradient>
</defs>
</svg>
Dev UI
</div>
<EntitySelector
agents={agents}
workflows={workflows}
@@ -51,4 +79,4 @@ export function AppHeader({
</div>
</header>
);
}
}
@@ -7,6 +7,7 @@ import { useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Activity,
Search,
@@ -19,6 +20,7 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -65,6 +67,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onClose?: () => void;
}
// Helper function to accumulate OpenAI events into meaningful units
@@ -1359,12 +1362,16 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
);
}
export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
export function DebugPanel({
events,
isStreaming = false,
onClose,
}: DebugPanelProps) {
return (
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
<Tabs defaultValue="events" className="h-full flex flex-col">
<div className="px-3 pt-3">
<TabsList className="w-full">
<div className="px-3 pt-3 flex items-center gap-2">
<TabsList className="flex-1">
<TabsTrigger value="events" className="flex-1">
Events
</TabsTrigger>
@@ -1375,6 +1382,17 @@ export function DebugPanel({ events, isStreaming = false }: DebugPanelProps) {
Tools
</TabsTrigger>
</TabsList>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 flex-shrink-0"
title="Hide debug panel"
>
<PanelRightClose className="h-4 w-4" />
</Button>
)}
</div>
<TabsContent value="events" className="flex-1 mt-0">
@@ -0,0 +1,168 @@
/**
* WorkflowDetailsModal - Responsive grid-based modal for displaying workflow metadata
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Workflow as WorkflowIcon,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
PlayCircle,
} from "lucide-react";
import type { WorkflowInfo } from "@/types";
interface WorkflowDetailsModalProps {
workflow: WorkflowInfo;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface DetailCardProps {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function DetailCard({ title, icon, children, className = "" }: DetailCardProps) {
return (
<div className={`border rounded-lg p-4 bg-card ${className}`}>
<div className="flex items-center gap-2 mb-3">
{icon}
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
export function WorkflowDetailsModal({
workflow,
open,
onOpenChange,
}: WorkflowDetailsModalProps) {
const sourceIcon =
workflow.source === "directory" ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : workflow.source === "in_memory" ? (
<Database className="h-4 w-4 text-muted-foreground" />
) : (
<Globe className="h-4 w-4 text-muted-foreground" />
);
const sourceLabel =
workflow.source === "directory"
? "Local"
: workflow.source === "in_memory"
? "In-Memory"
: "Gallery";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-6 flex-shrink-0">
<DialogTitle>Workflow Details</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<WorkflowIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">
{workflow.name || workflow.id}
</h2>
</div>
{workflow.description && (
<p className="text-muted-foreground">{workflow.description}</p>
)}
</div>
<div className="h-px bg-border mb-6" />
{/* Grid Layout for Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Start Executor */}
<DetailCard
title="Start Executor"
icon={<PlayCircle className="h-4 w-4 text-muted-foreground" />}
>
<div className="font-mono text-foreground">
{workflow.start_executor_id}
</div>
</DetailCard>
{/* Source */}
<DetailCard title="Source" icon={sourceIcon}>
<div className="space-y-1">
<div className="text-foreground">{sourceLabel}</div>
{workflow.module_path && (
<div className="font-mono text-xs break-all">
{workflow.module_path}
</div>
)}
</div>
</DetailCard>
{/* Environment */}
<DetailCard
title="Environment"
icon={
workflow.has_env ? (
<XCircle className="h-4 w-4 text-orange-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)
}
className="md:col-span-2"
>
<div
className={
workflow.has_env
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{workflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</div>
</DetailCard>
</div>
{/* Executors */}
<DetailCard
title={`Executors (${workflow.executors.length})`}
icon={<Package className="h-4 w-4 text-muted-foreground" />}
>
{workflow.executors.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{workflow.executors.map((executor, index) => (
<div
key={index}
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
>
{executor}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">No executors configured</div>
)}
</DetailCard>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,89 @@
/**
* Simple toast notification component
* Displays floating notifications in the top-right corner
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
export interface ToastProps {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
onClose: () => void;
}
export function Toast({ message, type = "info", duration = 4000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const bgColorClass = {
info: "bg-primary/10 border-primary/20",
success: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
warning: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
error: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800",
}[type];
const textColorClass = {
info: "text-primary",
success: "text-green-800 dark:text-green-200",
warning: "text-orange-800 dark:text-orange-200",
error: "text-red-800 dark:text-red-200",
}[type];
return (
<div
className={`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-4"
} ${bgColorClass}`}
>
<p className={`text-sm flex-1 ${textColorClass}`}>{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className={`flex-shrink-0 hover:opacity-70 transition-opacity ${textColorClass}`}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
// Toast container for managing multiple toasts
export interface ToastData {
id: string;
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}
interface ToastContainerProps {
toasts: ToastData[];
onRemove: (id: string) => void;
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => onRemove(toast.id)}
/>
))}
</div>
);
}
@@ -20,7 +20,7 @@ import {
DialogClose,
DialogFooter,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { Send, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { JSONSchemaProperty } from "@/types";
@@ -29,22 +29,27 @@ interface FormFieldProps {
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
}
function FormField({ name, schema, value, onChange }: FormFieldProps) {
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// For text/message/content fields, treat as textarea for better UX
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
// Determine if this field should span full width
// Only span full if it's a textarea or has very long description
const shouldSpanFullWidth =
schema.format === "textarea" ||
(description && description.length > 100) ||
type === "object" ||
type === "array";
isTextContentField || // text/message fields span full width
(description && description.length > 150);
const shouldSpanTwoColumns =
type === "object" ||
schema.format === "textarea" ||
(description && description.length > 50);
isTextContentField ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
const fieldContent = (() => {
// Handle different field types based on JSON Schema
@@ -54,7 +59,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
@@ -83,12 +91,16 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
);
} else if (
schema.format === "textarea" ||
isTextContentField ||
(description && description.length > 100)
) {
// Multi-line text
// Multi-line text (including text/message/content fields)
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
@@ -98,7 +110,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
? defaultValue
: `Enter ${name}`
}
rows={2}
rows={isTextContentField ? 4 : 2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -109,7 +121,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
@@ -131,7 +146,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
@@ -161,7 +179,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -172,7 +193,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
@@ -203,7 +227,10 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// For complex objects or unknown types, use JSON textarea
return (
<div className="space-y-2">
<Label htmlFor={name}>{name}</Label>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
@@ -235,7 +262,7 @@ function FormField({ name, schema, value, onChange }: FormFieldProps) {
// Return the field with appropriate grid column spanning
const getColumnSpan = () => {
if (shouldSpanFullWidth) return "md:col-span-3 xl:col-span-4";
if (shouldSpanFullWidth) return "md:col-span-2 lg:col-span-3 xl:col-span-4";
if (shouldSpanTwoColumns) return "xl:col-span-2";
return "";
};
@@ -259,6 +286,7 @@ export function WorkflowInputForm({
className,
}: WorkflowInputFormProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Check if we're in embedded mode (being used inside another modal)
const isEmbedded = className?.includes('embedded');
@@ -268,10 +296,55 @@ export function WorkflowInputForm({
// Determine field info
const properties = inputSchema.properties || {};
const fieldNames = Object.keys(properties);
const requiredFields = inputSchema.required || [];
const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum;
const primaryField = isSimpleInput ? "value" : fieldNames[0];
const canSubmit = primaryField
? formData[primaryField] !== undefined && formData[primaryField] !== ""
// Plan D: Separate required and optional fields first
const allOptionalFieldNames = fieldNames.filter(name => !requiredFields.includes(name));
// Detect ChatMessage-like pattern
const isChatMessageLike =
requiredFields.includes('role') &&
allOptionalFieldNames.some(f => ['text', 'message', 'content'].includes(f)) &&
properties['role']?.type === 'string';
// For ChatMessage: hide 'role' field (will be auto-filled)
const requiredFieldNames = fieldNames.filter(name =>
requiredFields.includes(name) && !(isChatMessageLike && name === 'role')
);
const optionalFieldNames = allOptionalFieldNames;
// For ChatMessage: prioritize text/message/content field to show first
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
['text', 'message', 'content'].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Always show ALL required fields + fill to minimum visible with optional fields
// For ChatMessage: show only 1 optional field (text)
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;
// Update canSubmit to check required fields properly
// For ChatMessage: role is auto-filled, so it's always valid
const canSubmit = isSimpleInput
? formData.value !== undefined && formData.value !== ""
: requiredFields.length > 0
? requiredFields.every(fieldName => {
// Auto-filled fields are always valid
if (isChatMessageLike && fieldName === 'role' && formData['role'] === 'user') {
return true;
}
return formData[fieldName] !== undefined && formData[fieldName] !== "";
})
: Object.keys(formData).length > 0;
// Initialize form data
@@ -287,9 +360,15 @@ export function WorkflowInputForm({
initialData[key] = fieldSchema.enum[0];
}
});
// Auto-fill role="user" for ChatMessage-like inputs
if (isChatMessageLike && !initialData['role']) {
initialData['role'] = 'user';
}
setFormData(initialData);
}
}, [inputSchema]);
}, [inputSchema, isChatMessageLike]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -306,7 +385,16 @@ export function WorkflowInputForm({
const fieldName = fieldNames[0];
onSubmit({ [fieldName]: formData[fieldName] || "" });
} else {
onSubmit(formData);
// Filter out empty optional fields before submission
const filteredData: Record<string, unknown> = {};
Object.keys(formData).forEach(key => {
const value = formData[key];
// Include if: 1) required field, OR 2) has non-empty value
if (requiredFields.includes(key) || (value !== undefined && value !== "" && value !== null)) {
filteredData[key] = value;
}
});
onSubmit(filteredData);
}
} else {
onSubmit(formData);
@@ -330,27 +418,84 @@ export function WorkflowInputForm({
if (isEmbedded) {
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Simple input */}
{isSimpleInput && primaryField && (
{isSimpleInput && (
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
)}
{/* Complex form fields */}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="sm:col-span-2 border-t border-border my-2"></div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="sm:col-span-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
>
{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 - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
@@ -440,15 +585,16 @@ export function WorkflowInputForm({
{/* Scrollable Form Content */}
<div className="px-8 py-6 overflow-y-auto flex-1 min-h-0">
<form id="workflow-modal-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-8 max-w-none">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 max-w-none">
{/* Simple input */}
{isSimpleInput && primaryField && (
<div className="md:col-span-3 xl:col-span-4">
{isSimpleInput && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<FormField
name="Input"
schema={inputSchema}
value={formData.value}
onChange={(value) => updateField("value", value)}
isRequired={false}
/>
{inputSchema.description && (
<p className="text-sm text-muted-foreground mt-2">
@@ -458,16 +604,74 @@ export function WorkflowInputForm({
</div>
)}
{/* Complex form fields - Show all */}
{/* Complex form fields - Plan D: Required + Optional separation */}
{!isSimpleInput && (
<>
{fieldNames.map((fieldName) => (
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
/>
))}
{/* Separator between required and optional (only if both exist) */}
{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={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
{/* 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"
>
{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 - only show when toggled */}
{showAdvancedFields && collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={formData[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
/>
))}
</>
@@ -11,12 +11,7 @@ import {
Play,
Settings,
RotateCcw,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
XCircle,
Info,
Workflow as WorkflowIcon,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
@@ -24,6 +19,7 @@ import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
import { Button } from "@/components/ui/button";
import { WorkflowFlow } from "@/components/workflow/workflow-flow";
import { useWorkflowEventCorrelation } from "@/hooks/useWorkflowEventCorrelation";
import { WorkflowDetailsModal } from "@/components/shared/workflow-details-modal";
import { apiClient } from "@/services/api";
import type {
WorkflowInfo,
@@ -190,11 +186,12 @@ function RunWorkflowButton({
variant={
buttonVariant === "destructive" ? "destructive" : "default"
}
size="icon"
className="rounded-l-none border-l-0 w-9"
title="Configure inputs"
size="default"
className="rounded-l-none border-l-0 px-3"
title="Configure workflow inputs - customize parameters before running"
>
<ChevronDown className="w-4 h-4" />
<Settings className="w-4 h-4" />
<span className="ml-1.5">Inputs</span>
</Button>
)}
</div>
@@ -273,7 +270,7 @@ export function WorkflowView({
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
@@ -554,14 +551,11 @@ export function WorkflowView({
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View workflow details"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
<Info className="h-4 w-4" />
</Button>
</div>
@@ -593,78 +587,6 @@ export function WorkflowView({
{selectedWorkflow.description}
</p>
)}
{/* Executors - Always visible */}
{selectedWorkflow.executors.length > 0 && (
<div className="flex items-center gap-2 text-xs mt-2 mb-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Executors:</span>
<span className="font-mono">
{selectedWorkflow.executors.slice(0, 3).join(", ")}
{selectedWorkflow.executors.length > 3 && "..."}
</span>
<span className="text-muted-foreground">
({selectedWorkflow.executors.length})
</span>
</div>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Start Executor */}
<div className="flex items-center gap-2">
<WorkflowIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Start:</span>
<span className="font-mono">
{selectedWorkflow.start_executor_id}
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedWorkflow.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedWorkflow.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedWorkflow.source === "directory"
? "Local"
: selectedWorkflow.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedWorkflow.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedWorkflow.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedWorkflow.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedWorkflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Workflow Visualization */}
@@ -954,6 +876,13 @@ export function WorkflowView({
)}
</div>
</div>
{/* Workflow Details Modal */}
<WorkflowDetailsModal
workflow={selectedWorkflow}
open={detailsModalOpen}
onOpenChange={setDetailsModalOpen}
/>
</div>
);
}
@@ -10,12 +10,14 @@ import type {
RunAgentRequest,
RunWorkflowRequest,
ThreadInfo,
WorkflowInfo,
} from "@/types";
import type { AgentFrameworkRequest } from "@/types/agent-framework";
import type { ExtendedResponseStreamEvent } from "@/types/openai";
// Backend API response types to match Python Pydantic models
interface EntityInfo {
// Backend API response type - polymorphic entity that can be agent or workflow
// This matches the Python Pydantic EntityInfo model which has all fields optional
interface BackendEntityInfo {
id: string;
type: "agent" | "workflow";
name: string;
@@ -25,6 +27,13 @@ interface EntityInfo {
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
chat_client_type?: string;
context_providers?: string[];
middleware?: string[];
// Workflow-specific fields (present when type === "workflow")
executors?: string[];
workflow_dump?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
@@ -33,7 +42,7 @@ interface EntityInfo {
}
interface DiscoveryResponse {
entities: EntityInfo[];
entities: BackendEntityInfo[];
}
interface ThreadApiResponse {
@@ -119,15 +128,15 @@ class ApiClient {
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | import("@/types").WorkflowInfo)[];
entities: (AgentInfo | WorkflowInfo)[];
agents: AgentInfo[];
workflows: import("@/types").WorkflowInfo[];
workflows: WorkflowInfo[];
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: import("@/types").WorkflowInfo[] = [];
const workflows: WorkflowInfo[] = [];
response.entities.forEach((entity) => {
if (entity.type === "agent") {
@@ -145,6 +154,12 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
// 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") {
const firstTool = entity.tools?.[0];
@@ -183,7 +198,7 @@ class ApiClient {
return agents;
}
async getWorkflows(): Promise<import("@/types").WorkflowInfo[]> {
async getWorkflows(): Promise<WorkflowInfo[]> {
const { workflows } = await this.getEntities();
return workflows;
}
@@ -467,8 +482,8 @@ class ApiClient {
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<EntityInfo> {
const response = await this.request<{ success: boolean; entity: EntityInfo }>("/v1/entities/add", {
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/v1/entities/add", {
method: "POST",
body: JSON.stringify({ url, metadata }),
});
@@ -31,6 +31,12 @@ export interface AgentInfo {
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
// Agent-specific fields
instructions?: string;
model?: string;
chat_client_type?: string;
context_providers?: string[];
middleware?: string[];
}
// JSON Schema types for workflow input
@@ -133,6 +139,11 @@ export interface ChatMessage {
author_name?: string;
message_id?: string;
error?: boolean; // Flag to indicate this is an error message
usage?: {
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
};
}
// UI State types
+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251001"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
@@ -2,12 +2,73 @@
"""Sample weather agent for Agent Framework Debug UI."""
import os
from collections.abc import Awaitable, Callable
from typing import Annotated
from agent_framework import ChatAgent
from agent_framework import (
ChatAgent,
ChatContext,
ChatMessage,
ChatResponse,
FunctionInvocationContext,
Role,
chat_middleware,
function_middleware,
)
from agent_framework.azure import AzureOpenAIChatClient
@chat_middleware
async def security_filter_middleware(
context: ChatContext,
next: Callable[[ChatContext], Awaitable[None]],
) -> None:
"""Chat middleware that blocks requests containing sensitive information."""
# Block requests with sensitive information
blocked_terms = ["password", "secret", "api_key", "token"]
for message in context.messages:
if message.text:
message_lower = message.text.lower()
for term in blocked_terms:
if term in message_lower:
# Override the response without calling the LLM
context.result = ChatResponse(
messages=[
ChatMessage(
role=Role.ASSISTANT,
text=(
"I cannot process requests containing sensitive information. "
"Please rephrase your question without including passwords, secrets, "
"or other sensitive data."
),
)
]
)
return
await next(context)
@function_middleware
async def atlantis_location_filter_middleware(
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
"""Function middleware that blocks weather requests for Atlantis."""
# Check if location parameter is "atlantis"
location = getattr(context.arguments, "location", None)
if location and location.lower() == "atlantis":
context.result = (
"Blocked! Hold up right there!! Tell the user that "
"'Atlantis is a special place, we must never ask about the weather there!!'"
)
context.terminate = True
return
await next(context)
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
@@ -46,6 +107,7 @@ agent = ChatAgent(
api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""),
),
tools=[get_weather, get_forecast],
middleware=[security_filter_middleware, atlantis_location_filter_middleware],
)
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sequential Agents Workflow - Writer → Reviewer."""
from .workflow import workflow
__all__ = ["workflow"]
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Workflow - Content Review with Quality Routing.
This sample demonstrates:
- Using agents directly as executors
- Conditional routing based on structured outputs
- Quality-based workflow paths with convergence
Use case: Content creation with automated review.
Writer creates content, Reviewer evaluates quality:
- High quality (score >= 80): Publisher Summarizer
- Low quality (score < 80): Editor Publisher Summarizer
Both paths converge at Summarizer for final report.
"""
import os
from typing import Any
from agent_framework import AgentExecutorResponse, WorkflowBuilder
from agent_framework.azure import AzureOpenAIChatClient
from pydantic import BaseModel
# Define structured output for review results
class ReviewResult(BaseModel):
"""Review evaluation with scores and feedback."""
score: int # Overall quality score (0-100)
feedback: str # Concise, actionable feedback
clarity: int # Clarity score (0-100)
completeness: int # Completeness score (0-100)
accuracy: int # Accuracy score (0-100)
structure: int # Structure score (0-100)
# Condition function: route to editor if score < 80
def needs_editing(message: Any) -> bool:
"""Check if content needs editing based on review score."""
if not isinstance(message, AgentExecutorResponse):
return False
try:
review = ReviewResult.model_validate_json(message.agent_run_response.text)
return review.score < 80
except Exception:
return False
# Condition function: content is approved (score >= 80)
def is_approved(message: Any) -> bool:
"""Check if content is approved (high quality)."""
if not isinstance(message, AgentExecutorResponse):
return True
try:
review = ReviewResult.model_validate_json(message.agent_run_response.text)
return review.score >= 80
except Exception:
return True
# Create Azure OpenAI chat client
chat_client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""))
# Create Writer agent - generates content
writer = chat_client.create_agent(
name="Writer",
instructions=(
"You are an excellent content writer. "
"Create clear, engaging content based on the user's request. "
"Focus on clarity, accuracy, and proper structure."
),
)
# Create Reviewer agent - evaluates and provides structured feedback
reviewer = chat_client.create_agent(
name="Reviewer",
instructions=(
"You are an expert content reviewer. "
"Evaluate the writer's content based on:\n"
"1. Clarity - Is it easy to understand?\n"
"2. Completeness - Does it fully address the topic?\n"
"3. Accuracy - Is the information correct?\n"
"4. Structure - Is it well-organized?\n\n"
"Return a JSON object with:\n"
"- score: overall quality (0-100)\n"
"- feedback: concise, actionable feedback\n"
"- clarity, completeness, accuracy, structure: individual scores (0-100)"
),
response_format=ReviewResult,
)
# Create Editor agent - improves content based on feedback
editor = chat_client.create_agent(
name="Editor",
instructions=(
"You are a skilled editor. "
"You will receive content along with review feedback. "
"Improve the content by addressing all the issues mentioned in the feedback. "
"Maintain the original intent while enhancing clarity, completeness, accuracy, and structure."
),
)
# Create Publisher agent - formats content for publication
publisher = chat_client.create_agent(
name="Publisher",
instructions=(
"You are a publishing agent. "
"You receive either approved content or edited content. "
"Format it for publication with proper headings and structure."
),
)
# Create Summarizer agent - creates final publication report
summarizer = chat_client.create_agent(
name="Summarizer",
instructions=(
"You are a summarizer agent. "
"Create a final publication report that includes:\n"
"1. A brief summary of the published content\n"
"2. The workflow path taken (direct approval or edited)\n"
"3. Key highlights and takeaways\n"
"Keep it concise and professional."
),
)
# Build workflow with branching and convergence:
# Writer → Reviewer → [branches]:
# - If score >= 80: → Publisher → Summarizer (direct approval path)
# - If score < 80: → Editor → Publisher → Summarizer (improvement path)
# Both paths converge at Summarizer for final report
workflow = (
WorkflowBuilder()
.set_start_executor(writer)
.add_edge(writer, reviewer)
# Branch 1: High quality (>= 80) goes directly to publisher
.add_edge(reviewer, publisher, condition=is_approved)
# Branch 2: Low quality (< 80) goes to editor first, then publisher
.add_edge(reviewer, editor, condition=needs_editing)
.add_edge(editor, publisher)
# Both paths converge: Publisher → Summarizer
.add_edge(publisher, summarizer)
.build()
)
def main():
"""Launch the branching workflow in DevUI."""
import logging
from agent_framework.devui import serve
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
logger.info("Starting Agent Workflow (Content Review with Quality Routing)")
logger.info("Available at: http://localhost:8093")
logger.info("\nThis workflow demonstrates:")
logger.info("- Conditional routing based on structured outputs")
logger.info("- Path 1 (score >= 80): Reviewer → Publisher → Summarizer")
logger.info("- Path 2 (score < 80): Reviewer → Editor → Publisher → Summarizer")
logger.info("- Both paths converge at Summarizer for final report")
serve(entities=[workflow], port=8093, auto_open=True)
if __name__ == "__main__":
main()
+2 -235
View File
@@ -158,217 +158,6 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
assert "WeirdUnknownContent" in event.delta
def test_serialize_payload_primitives(mapper: MessageMapper) -> None:
"""Test serialization of primitive types."""
assert mapper._serialize_payload(None) is None
assert mapper._serialize_payload("test") == "test"
assert mapper._serialize_payload(42) == 42
assert mapper._serialize_payload(3.14) == 3.14
assert mapper._serialize_payload(True) is True
assert mapper._serialize_payload(False) is False
def test_serialize_payload_sequences(mapper: MessageMapper) -> None:
"""Test serialization of lists, tuples, and sets."""
# List
result = mapper._serialize_payload([1, 2, "three"])
assert result == [1, 2, "three"]
assert isinstance(result, list)
# Tuple - should convert to list
result = mapper._serialize_payload((1, 2, "three"))
assert result == [1, 2, "three"]
assert isinstance(result, list)
# Set - should convert to list (order may vary)
result = mapper._serialize_payload({1, 2, 3})
assert isinstance(result, list)
assert set(result) == {1, 2, 3}
# Nested sequences
result = mapper._serialize_payload([1, [2, 3], (4, 5)])
assert result == [1, [2, 3], [4, 5]]
def test_serialize_payload_dicts(mapper: MessageMapper) -> None:
"""Test serialization of dictionaries."""
# Simple dict
result = mapper._serialize_payload({"a": 1, "b": 2})
assert result == {"a": 1, "b": 2}
# Dict with non-string keys (should convert to string)
result = mapper._serialize_payload({1: "one", 2: "two"})
assert result == {"1": "one", "2": "two"}
# Nested dicts
result = mapper._serialize_payload({"outer": {"inner": {"deep": 42}}})
assert result == {"outer": {"inner": {"deep": 42}}}
# Dict with mixed value types
result = mapper._serialize_payload({"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}})
assert result == {"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}}
def test_serialize_payload_dataclass(mapper: MessageMapper) -> None:
"""Test serialization of dataclasses."""
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
active: bool = True
person = Person(name="Alice", age=30)
result = mapper._serialize_payload(person)
assert result == {"name": "Alice", "age": 30, "active": True}
assert isinstance(result, dict)
def test_serialize_payload_pydantic_model(mapper: MessageMapper) -> None:
"""Test serialization of Pydantic models."""
from pydantic import BaseModel
class User(BaseModel):
username: str
email: str
is_active: bool = True
user = User(username="testuser", email="test@example.com")
result = mapper._serialize_payload(user)
assert result == {"username": "testuser", "email": "test@example.com", "is_active": True}
assert isinstance(result, dict)
def test_serialize_payload_nested_pydantic(mapper: MessageMapper) -> None:
"""Test serialization of nested Pydantic models."""
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
class Person(BaseModel):
name: str
address: Address
person = Person(name="Bob", address=Address(street="123 Main St", city="Springfield"))
result = mapper._serialize_payload(person)
assert result == {"name": "Bob", "address": {"street": "123 Main St", "city": "Springfield"}}
def test_serialize_payload_object_with_dict_method(mapper: MessageMapper) -> None:
"""Test serialization of objects with dict() method."""
class CustomObject:
def __init__(self):
self.value = 42
def dict(self):
return {"value": self.value, "type": "custom"}
obj = CustomObject()
result = mapper._serialize_payload(obj)
assert result == {"value": 42, "type": "custom"}
def test_serialize_payload_object_with_to_dict_method(mapper: MessageMapper) -> None:
"""Test serialization of objects with to_dict() method."""
class CustomObject:
def __init__(self):
self.value = 42
def to_dict(self):
return {"value": self.value, "type": "custom_to_dict"}
obj = CustomObject()
result = mapper._serialize_payload(obj)
assert result == {"value": 42, "type": "custom_to_dict"}
def test_serialize_payload_object_with_model_dump_json(mapper: MessageMapper) -> None:
"""Test serialization of objects with model_dump_json() method."""
import json
class CustomObject:
def __init__(self):
self.value = 42
def model_dump_json(self):
return json.dumps({"value": self.value, "type": "json_dump"})
obj = CustomObject()
result = mapper._serialize_payload(obj)
assert result == {"value": 42, "type": "json_dump"}
def test_serialize_payload_object_with_dict_attr(mapper: MessageMapper) -> None:
"""Test serialization of objects with __dict__ attribute."""
class SimpleObject:
def __init__(self):
self.public_value = 42
self._private_value = 100 # Should be excluded
obj = SimpleObject()
result = mapper._serialize_payload(obj)
assert "public_value" in result
assert result["public_value"] == 42
assert "_private_value" not in result
def test_serialize_payload_fallback_to_string(mapper: MessageMapper) -> None:
"""Test that unserializable objects fall back to string representation."""
class WeirdObject:
__slots__ = () # Prevent __dict__ attribute
def __str__(self):
return "weird_object_string"
obj = WeirdObject()
result = mapper._serialize_payload(obj)
assert result == "weird_object_string"
def test_serialize_payload_complex_nested(mapper: MessageMapper) -> None:
"""Test serialization of complex nested structures."""
from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class DataItem:
value: int
class ConfigModel(BaseModel):
enabled: bool
count: int
complex_data = {
"items": [DataItem(value=1), DataItem(value=2)],
"config": ConfigModel(enabled=True, count=5),
"nested": {"list": [1, 2, 3], "tuple": (4, 5, 6)},
"primitive": 42,
}
result = mapper._serialize_payload(complex_data)
assert result["items"] == [{"value": 1}, {"value": 2}]
assert result["config"] == {"enabled": True, "count": 5}
assert result["nested"] == {"list": [1, 2, 3], "tuple": [4, 5, 6]}
assert result["primitive"] == 42
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
@@ -377,7 +166,7 @@ if __name__ == "__main__":
model="agent-framework", input="Test", stream=True, extra_body=AgentFrameworkExtraBody(entity_id="test")
)
async_tests = [
tests = [
("Critical isinstance bug detection", test_critical_isinstance_bug_detection),
("Text content mapping", test_text_content_mapping),
("Function call mapping", test_function_call_mapping),
@@ -386,34 +175,12 @@ if __name__ == "__main__":
("Unknown content fallback", test_unknown_content_fallback),
]
sync_tests = [
("Serialize primitives", test_serialize_payload_primitives),
("Serialize sequences", test_serialize_payload_sequences),
("Serialize dicts", test_serialize_payload_dicts),
("Serialize dataclass", test_serialize_payload_dataclass),
("Serialize pydantic model", test_serialize_payload_pydantic_model),
("Serialize nested pydantic", test_serialize_payload_nested_pydantic),
("Serialize dict method", test_serialize_payload_object_with_dict_method),
("Serialize to_dict method", test_serialize_payload_object_with_to_dict_method),
("Serialize model_dump_json", test_serialize_payload_object_with_model_dump_json),
("Serialize __dict__ attr", test_serialize_payload_object_with_dict_attr),
("Serialize fallback to string", test_serialize_payload_fallback_to_string),
("Serialize complex nested", test_serialize_payload_complex_nested),
]
passed = 0
for _test_name, test_func in async_tests:
for _test_name, test_func in tests:
try:
await test_func(mapper, test_request)
passed += 1
except Exception:
pass
for _test_name, test_func in sync_tests:
try:
test_func(mapper)
passed += 1
except Exception:
pass
asyncio.run(run_all_tests())
@@ -0,0 +1,137 @@
# Copyright (c) Microsoft. All rights reserved.
"""Test schema generation for different input types."""
import sys
from dataclasses import dataclass
from pathlib import Path
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
@dataclass
class InputData:
text: str
source: str
@dataclass
class Address:
street: str
city: str
zipcode: str
@dataclass
class PersonData:
name: str
age: int
address: Address
def test_builtin_types_schema_generation():
"""Test schema generation for built-in types."""
# Test str schema
str_schema = generate_input_schema(str)
assert str_schema is not None
assert isinstance(str_schema, dict)
# Test dict schema
dict_schema = generate_input_schema(dict)
assert dict_schema is not None
assert isinstance(dict_schema, dict)
# Test int schema
int_schema = generate_input_schema(int)
assert int_schema is not None
assert isinstance(int_schema, dict)
def test_dataclass_schema_generation():
"""Test schema generation for dataclass."""
schema = generate_input_schema(InputData)
assert schema is not None
assert isinstance(schema, dict)
# Basic schema structure checks
if "properties" in schema:
properties = schema["properties"]
assert "text" in properties
assert "source" in properties
def test_chat_message_schema_generation():
"""Test schema generation for ChatMessage (SerializationMixin)."""
try:
from agent_framework import ChatMessage
schema = generate_input_schema(ChatMessage)
assert schema is not None
assert isinstance(schema, dict)
except ImportError:
pytest.skip("ChatMessage not available - agent_framework not installed")
def test_pydantic_model_schema_generation():
"""Test schema generation for Pydantic models."""
try:
from pydantic import BaseModel, Field
class UserInput(BaseModel):
name: str = Field(description="User's name")
age: int = Field(description="User's age")
email: str | None = Field(default=None, description="Optional email")
schema = generate_input_schema(UserInput)
assert schema is not None
assert isinstance(schema, dict)
# Check if properties exist
if "properties" in schema:
properties = schema["properties"]
assert "name" in properties
assert "age" in properties
assert "email" in properties
except ImportError:
pytest.skip("Pydantic not available")
def test_nested_dataclass_schema_generation():
"""Test schema generation for nested dataclass."""
schema = generate_input_schema(PersonData)
assert schema is not None
assert isinstance(schema, dict)
# Basic schema structure checks
if "properties" in schema:
properties = schema["properties"]
assert "name" in properties
assert "age" in properties
assert "address" in properties
def test_schema_generation_error_handling():
"""Test schema generation with invalid inputs."""
# Test with a non-type object - should handle gracefully
try:
# Use a non-type object that might cause issues
schema = generate_input_schema("not_a_type") # type: ignore
# If it doesn't raise an exception, the result should be valid
if schema is not None:
assert isinstance(schema, dict)
except (TypeError, ValueError, AttributeError):
# It's acceptable for this to raise an error
pass
if __name__ == "__main__":
# Simple test runner for manual execution
pytest.main([__file__, "-v"])