mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: make tool call view optional in DevUI + other link fixes (#2243)
* make tool call view optional in devui + other link fixes * fix #2310, ensure correct port is shown in command * fix dialog bug * ensure executor ids are tracked per items, fix bug where data from concurrent executors where not seperated properly fix #2351 * fix: Enable multi-round human-in-the-loop (HIL) in DevUI workflows - Backend: Enrich RequestInfoEvents with response schemas in send_responses_streaming path - Frontend: Replace old HIL requests with new ones instead of accumulating them - Frontend: Fix HIL response state management to prevent sending stale request responses This allows workflows to properly handle sequential HIL requests, showing only the current request to users and progressing through multiple input rounds correctly. fixes #2334 * fix bug to ensure in memory entities cannot be reloaded in ui
This commit is contained in:
committed by
GitHub
Unverified
parent
dc2b109b50
commit
f0f1051c7d
@@ -229,6 +229,15 @@ class EntityDiscovery:
|
||||
Args:
|
||||
entity_id: Entity identifier to invalidate
|
||||
"""
|
||||
# Check if entity is in-memory - these cannot be invalidated
|
||||
entity_info = self._entities.get(entity_id)
|
||||
if entity_info and entity_info.source == "in_memory":
|
||||
logger.warning(
|
||||
f"Attempted to invalidate in-memory entity {entity_id} - ignoring "
|
||||
f"(in-memory entities cannot be reloaded)"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove from loaded objects cache
|
||||
if entity_id in self._loaded_objects:
|
||||
del self._loaded_objects[entity_id]
|
||||
@@ -366,6 +375,7 @@ class EntityDiscovery:
|
||||
description=description,
|
||||
type=entity_type,
|
||||
framework="agent_framework",
|
||||
source=source, # IMPORTANT: Pass the source parameter
|
||||
tools=[str(tool) for tool in (tools_list or [])],
|
||||
instructions=instructions,
|
||||
model_id=model,
|
||||
|
||||
@@ -464,8 +464,11 @@ class AgentFrameworkExecutor:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not convert HIL responses to proper types: {e}")
|
||||
|
||||
# Step 2: Now send responses to the in-memory workflow
|
||||
async for event in workflow.send_responses_streaming(hil_responses):
|
||||
# Enrich new RequestInfoEvents that may come from subsequent HIL requests
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
self._enrich_request_info_event_with_response_schema(event, workflow)
|
||||
|
||||
for trace_event in trace_collector.get_pending_events():
|
||||
yield trace_event
|
||||
yield event
|
||||
|
||||
@@ -29,7 +29,6 @@ from .models import (
|
||||
InputTokensDetails,
|
||||
OpenAIResponse,
|
||||
OutputTokensDetails,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
@@ -186,6 +185,8 @@ class MessageMapper:
|
||||
if isinstance(raw_event, AgentRunUpdateEvent):
|
||||
# Extract the AgentRunResponseUpdate from the event's data attribute
|
||||
if raw_event.data and isinstance(raw_event.data, AgentRunResponseUpdate):
|
||||
# Preserve executor_id in context for proper output routing
|
||||
context["current_executor_id"] = raw_event.executor_id
|
||||
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)
|
||||
@@ -502,8 +503,17 @@ class MessageMapper:
|
||||
# Check if we're streaming text content
|
||||
has_text_content = any(content.__class__.__name__ == "TextContent" for content in update.contents)
|
||||
|
||||
# If we have text content and haven't created a message yet, create one
|
||||
if has_text_content and "current_message_id" not in context:
|
||||
# Check if we're in an executor context with an existing item
|
||||
executor_id = context.get("current_executor_id")
|
||||
executor_item_key = f"exec_item_{executor_id}" if executor_id else None
|
||||
|
||||
# If we have an executor item, use it for deltas instead of creating a message
|
||||
if has_text_content and executor_item_key and executor_item_key in context:
|
||||
# Use the executor's item ID for this agent's output
|
||||
context["current_message_id"] = context[executor_item_key]
|
||||
# Note: We don't create a new message item here since the executor item already exists
|
||||
# Otherwise, create a message item if we haven't yet (for non-executor contexts)
|
||||
elif has_text_content and "current_message_id" not in context:
|
||||
message_id = f"msg_{uuid4().hex[:8]}"
|
||||
context["current_message_id"] = message_id
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
@@ -671,25 +681,9 @@ class MessageMapper:
|
||||
]
|
||||
|
||||
if isinstance(event, AgentCompletedEvent):
|
||||
execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}")
|
||||
|
||||
response_obj = Response(
|
||||
id=f"resp_{execution_id}",
|
||||
object="response",
|
||||
created_at=float(time.time()),
|
||||
model=model_name,
|
||||
output=[],
|
||||
status="completed",
|
||||
parallel_tool_calls=False,
|
||||
tool_choice="none",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
return [
|
||||
ResponseCompletedEvent(
|
||||
type="response.completed", sequence_number=self._next_sequence(context), response=response_obj
|
||||
)
|
||||
]
|
||||
# Don't emit response.completed here - the server will emit a proper one
|
||||
# with usage data after aggregating all events
|
||||
return []
|
||||
|
||||
if isinstance(event, AgentFailedEvent):
|
||||
execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}")
|
||||
@@ -839,35 +833,10 @@ class MessageMapper:
|
||||
)
|
||||
]
|
||||
|
||||
# Handle WorkflowCompletedEvent - emit response.completed
|
||||
# Handle WorkflowCompletedEvent - Don't emit response.completed here
|
||||
# The server will emit a proper one with usage data after aggregating all events
|
||||
if event_class == "WorkflowCompletedEvent":
|
||||
workflow_id = context.get("workflow_id", str(uuid4()))
|
||||
|
||||
# Import Response type for proper construction
|
||||
from openai.types.responses import Response
|
||||
|
||||
# Get model name from request or use 'devui' as default
|
||||
request_obj = context.get("request")
|
||||
model_name = request_obj.model if request_obj and request_obj.model else "devui"
|
||||
|
||||
# Create a full Response object for completed state
|
||||
response_obj = Response(
|
||||
id=f"resp_{workflow_id}",
|
||||
object="response",
|
||||
created_at=float(time.time()),
|
||||
model=model_name,
|
||||
output=[], # Output items already sent via output_item.added events
|
||||
status="completed",
|
||||
parallel_tool_calls=False,
|
||||
tool_choice="none",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
return [
|
||||
ResponseCompletedEvent(
|
||||
type="response.completed", sequence_number=self._next_sequence(context), response=response_obj
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
if event_class == "WorkflowFailedEvent":
|
||||
workflow_id = context.get("workflow_id", str(uuid4()))
|
||||
@@ -1103,7 +1072,7 @@ class MessageMapper:
|
||||
context[magentic_key] = message_id
|
||||
context["output_index"] = context.get("output_index", -1) + 1
|
||||
|
||||
# Import required types
|
||||
# Import required types for creating message containers
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses.response_content_part_added_event import (
|
||||
ResponseContentPartAddedEvent,
|
||||
|
||||
@@ -142,7 +142,7 @@ class DevServer:
|
||||
discovery = self.executor.entity_discovery
|
||||
for entity in self._pending_entities:
|
||||
try:
|
||||
entity_info = await discovery.create_entity_info_from_object(entity, source="in-memory")
|
||||
entity_info = await discovery.create_entity_info_from_object(entity, source="in_memory")
|
||||
discovery.register_entity(entity_info.id, entity_info, entity)
|
||||
logger.info(f"Registered in-memory entity: {entity_info.id}")
|
||||
except Exception as e:
|
||||
@@ -552,6 +552,14 @@ class DevServer:
|
||||
if not entity_info:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
# Check if entity is in-memory (cannot be reloaded)
|
||||
if entity_info.source == "in_memory":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="In-memory entities cannot be reloaded. "
|
||||
"They only exist in memory and have no source files to reload from.",
|
||||
)
|
||||
|
||||
# Invalidate cache
|
||||
executor.entity_discovery.invalidate_entity(entity_id)
|
||||
|
||||
@@ -1049,10 +1057,20 @@ class DevServer:
|
||||
from .models import ResponseCompletedEvent
|
||||
|
||||
final_response = await executor.message_mapper.aggregate_to_response(events, request)
|
||||
|
||||
# The sequence number for response.completed should be the next number after all events
|
||||
# The last event in the list should have the highest sequence number so far
|
||||
# We need to increment from that
|
||||
last_seq = 0
|
||||
for event in reversed(events):
|
||||
if hasattr(event, "sequence_number") and event.sequence_number is not None:
|
||||
last_seq = event.sequence_number
|
||||
break
|
||||
|
||||
completed_event = ResponseCompletedEvent(
|
||||
type="response.completed",
|
||||
response=final_response,
|
||||
sequence_number=len(events),
|
||||
sequence_number=last_seq + 1,
|
||||
)
|
||||
yield f"data: {completed_event.model_dump_json()}\n\n"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -330,6 +330,17 @@ export default function App() {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
|
||||
|
||||
// Extract port from the backend URL for the command suggestion
|
||||
let backendPort = "8080"; // default fallback
|
||||
try {
|
||||
if (currentBackendUrl) {
|
||||
const url = new URL(currentBackendUrl);
|
||||
backendPort = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, keep default
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
@@ -429,7 +440,7 @@ export default function App() {
|
||||
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
|
||||
devui ./agents --port {backendPort}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or launch programmatically with{" "}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Copy,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/services/api";
|
||||
import type {
|
||||
@@ -57,11 +58,16 @@ interface AgentViewProps {
|
||||
|
||||
interface ConversationItemBubbleProps {
|
||||
item: import("@/types/openai").ConversationItem;
|
||||
toolCalls?: import("@/types/openai").ConversationFunctionCall[];
|
||||
toolResults?: import("@/types/openai").ConversationFunctionCallOutput[];
|
||||
}
|
||||
|
||||
function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
function ConversationItemBubble({ item, toolCalls = [], toolResults = [] }: ConversationItemBubbleProps) {
|
||||
// All hooks must be at the top - cannot be conditional
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showToolDetails, setShowToolDetails] = useState(false); // For tool call expansion
|
||||
const showToolCalls = useDevUIStore((state) => state.showToolCalls);
|
||||
|
||||
// Extract text content from message for copying
|
||||
const getMessageText = () => {
|
||||
@@ -182,25 +188,81 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Tool calls badge */}
|
||||
{!isUser && showToolCalls && toolCalls.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={() => setShowToolDetails(!showToolDetails)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
title={`${toolCalls.length} tool call${toolCalls.length > 1 ? 's' : ''} - click to ${showToolDetails ? 'hide' : 'show'} details`}
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span>{toolCalls.length}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable tool call details */}
|
||||
{!isUser && showToolDetails && toolCalls.length > 0 && (
|
||||
<div className="mt-2 ml-0 p-3 bg-muted/30 rounded-md border border-muted">
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((call) => {
|
||||
// Find the matching result for this call
|
||||
const result = toolResults.find(r => r.call_id === call.call_id);
|
||||
|
||||
return (
|
||||
<div key={call.id} className="text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<Wrench className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-muted-foreground">
|
||||
<span className="text-blue-600 dark:text-blue-400">{call.name}</span>
|
||||
<span className="text-muted-foreground/60 ml-1">
|
||||
{call.arguments && (
|
||||
<span className="break-all">({call.arguments})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result && result.output && (
|
||||
<div className="mt-1 pl-5 border-l-2 border-green-600/20">
|
||||
<div className="flex items-start gap-1">
|
||||
<Check className="h-3 w-3 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<pre className="font-mono text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{call.status === "incomplete" && (
|
||||
<div className="mt-1 pl-5 border-l-2 border-orange-600/20">
|
||||
<div className="flex items-start gap-1">
|
||||
<X className="h-3 w-3 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="font-mono text-orange-600 dark:text-orange-400">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function calls and results - render with neutral styling
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-muted">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1 items-start max-w-[80%]">
|
||||
<div className="rounded px-3 py-2 text-sm bg-muted">
|
||||
<OpenAIMessageRenderer item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Function calls and results are now handled within message items
|
||||
// Don't render them as separate items anymore
|
||||
if (item.type === "function_call" || item.type === "function_call_output") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
@@ -351,7 +413,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
const error = failedEvent.response?.error;
|
||||
const errorMessage = error
|
||||
? typeof error === "object" && "message" in error
|
||||
? (error as any).message
|
||||
? (error as { message: string }).message
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
@@ -1336,6 +1398,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function call arguments delta (streaming arguments)
|
||||
if (openAIEvent.type === "response.function_call_arguments.delta") {
|
||||
const argsEvent = openAIEvent as import("@/types/openai").ResponseFunctionCallArgumentsDelta;
|
||||
|
||||
// Update the function call item with accumulated arguments
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) => {
|
||||
if (item.type === "function_call" && item.call_id === argsEvent.item_id) {
|
||||
return {
|
||||
...item,
|
||||
arguments: (item.arguments || "") + (argsEvent.delta || ""),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function result events (after function execution)
|
||||
if (openAIEvent.type === "response.function_result.complete") {
|
||||
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
|
||||
@@ -1382,11 +1462,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
return; // Exit stream processing early on error
|
||||
}
|
||||
|
||||
// Handle output item added events (images, files, data)
|
||||
// Handle output item added events (images, files, data, function calls)
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
|
||||
const item = outputItemEvent.item;
|
||||
|
||||
// Handle function calls as separate conversation items
|
||||
if (item.type === "function_call") {
|
||||
const functionCallItem: import("@/types/openai").ConversationFunctionCall = {
|
||||
id: item.id || `call-${Date.now()}`,
|
||||
type: "function_call",
|
||||
name: item.name,
|
||||
arguments: item.arguments || "",
|
||||
call_id: item.call_id,
|
||||
status: (item.status === "failed" || item.status === "cancelled" ? "incomplete" : item.status) || "in_progress",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems([...currentItems, functionCallItem]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add output items to assistant message content
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((chatItem) => {
|
||||
@@ -1664,22 +1761,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
selectedAgent.metadata?.source === "in_memory"
|
||||
? "In-memory entities cannot be reloaded"
|
||||
: isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
{/* Only show reload button for directory-based entities */}
|
||||
{selectedAgent.source !== "in_memory" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={isReloading}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1827,9 +1925,53 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
chatItems.map((item) => (
|
||||
<ConversationItemBubble key={item.id} item={item} />
|
||||
))
|
||||
(() => {
|
||||
// Group tool calls and results with their assistant messages
|
||||
const processedItems: React.ReactElement[] = [];
|
||||
const toolCallsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCall[]>();
|
||||
const toolResultsByMessage = new Map<string, import("@/types/openai").ConversationFunctionCallOutput[]>();
|
||||
|
||||
// First pass: collect tool calls and results, associate with preceding assistant message
|
||||
let lastAssistantMessageId: string | null = null;
|
||||
|
||||
for (const item of chatItems) {
|
||||
if (item.type === "message" && item.role === "assistant") {
|
||||
lastAssistantMessageId = item.id;
|
||||
// Initialize arrays for this message if they don't exist
|
||||
if (!toolCallsByMessage.has(item.id)) {
|
||||
toolCallsByMessage.set(item.id, []);
|
||||
toolResultsByMessage.set(item.id, []);
|
||||
}
|
||||
} else if (item.type === "function_call" && lastAssistantMessageId) {
|
||||
const calls = toolCallsByMessage.get(lastAssistantMessageId) || [];
|
||||
calls.push(item);
|
||||
toolCallsByMessage.set(lastAssistantMessageId, calls);
|
||||
} else if (item.type === "function_call_output" && lastAssistantMessageId) {
|
||||
const results = toolResultsByMessage.get(lastAssistantMessageId) || [];
|
||||
results.push(item);
|
||||
toolResultsByMessage.set(lastAssistantMessageId, results);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: render items, passing tool calls/results to assistant messages
|
||||
for (const item of chatItems) {
|
||||
if (item.type === "message") {
|
||||
const toolCalls = toolCallsByMessage.get(item.id) || [];
|
||||
const toolResults = toolResultsByMessage.get(item.id) || [];
|
||||
processedItems.push(
|
||||
<ConversationItemBubble
|
||||
key={item.id}
|
||||
item={item}
|
||||
toolCalls={toolCalls}
|
||||
toolResults={toolResults}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Tool calls and results are now rendered within messages, so skip them here
|
||||
}
|
||||
|
||||
return processedItems;
|
||||
})()
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
+15
-30
@@ -204,41 +204,29 @@ function DataContentRenderer({ content, className }: ContentRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Function approval request renderer
|
||||
// Function approval request renderer - compact version
|
||||
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
|
||||
if (content.type !== "function_approval_request") return null;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { status, function_call } = content;
|
||||
|
||||
// Status styling
|
||||
// Status styling - compact
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: "amber",
|
||||
label: "Awaiting Approval",
|
||||
bgClass: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderClass: "border-amber-200 dark:border-amber-800",
|
||||
label: "Awaiting approval",
|
||||
iconClass: "text-amber-600 dark:text-amber-400",
|
||||
textClass: "text-amber-800 dark:text-amber-300",
|
||||
},
|
||||
approved: {
|
||||
icon: Check,
|
||||
color: "green",
|
||||
label: "Approved",
|
||||
bgClass: "bg-green-50 dark:bg-green-950/20",
|
||||
borderClass: "border-green-200 dark:border-green-800",
|
||||
iconClass: "text-green-600 dark:text-green-400",
|
||||
textClass: "text-green-800 dark:text-green-300",
|
||||
},
|
||||
rejected: {
|
||||
icon: X,
|
||||
color: "red",
|
||||
label: "Rejected",
|
||||
bgClass: "bg-red-50 dark:bg-red-950/20",
|
||||
borderClass: "border-red-200 dark:border-red-800",
|
||||
iconClass: "text-red-600 dark:text-red-400",
|
||||
textClass: "text-red-800 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -255,27 +243,24 @@ function FunctionApprovalRequestRenderer({ content, className }: ContentRenderer
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit"
|
||||
>
|
||||
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
|
||||
<span className={`text-sm font-medium ${config.textClass}`}>
|
||||
{config.label}: {function_call.name}
|
||||
</span>
|
||||
<StatusIcon className={`h-3 w-3 ${config.iconClass}`} />
|
||||
<span className="text-muted-foreground font-mono">{function_call.name}</span>
|
||||
<span className={`text-xs ${config.iconClass}`}>{config.label}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
<span className="text-xs text-muted-foreground">▼</span>
|
||||
) : (
|
||||
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
|
||||
<span className="text-xs text-muted-foreground">▶</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
|
||||
<div className={`${config.textClass} mb-1`}>Arguments:</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedArgs, null, 2)}
|
||||
</pre>
|
||||
<div className="ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3">
|
||||
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(parsedArgs, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -222,7 +222,7 @@ export function ExecutionTimeline({
|
||||
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemAddedEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
@@ -261,7 +261,7 @@ export function ExecutionTimeline({
|
||||
|
||||
// Handle completion events
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
|
||||
|
||||
// Handle both executor_action items AND message items from Magentic agents
|
||||
if (item && item.type === "executor_action" && item.executor_id && item.id) {
|
||||
|
||||
@@ -727,7 +727,7 @@ export function WorkflowView({
|
||||
event.type === "response.output_item.added" ||
|
||||
event.type === "response.output_item.done"
|
||||
) {
|
||||
const item = (event as any).item;
|
||||
const item = (event as import("@/types/openai").ResponseOutputItemAddedEvent | import("@/types/openai").ResponseOutputItemDoneEvent).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
history.push({
|
||||
executorId: item.executor_id,
|
||||
@@ -835,7 +835,7 @@ export function WorkflowView({
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
? (prev[prev.length - 1] as any)._uiTimestamp || 0
|
||||
? (prev[prev.length - 1] as { _uiTimestamp?: number })._uiTimestamp || 0
|
||||
: 0;
|
||||
const uniqueTimestamp = Math.max(
|
||||
baseTimestamp,
|
||||
@@ -857,7 +857,7 @@ export function WorkflowView({
|
||||
|
||||
// Handle new standard OpenAI events
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const item = (openAIEvent as any).item;
|
||||
const item = (openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent).item;
|
||||
|
||||
// Handle executor action items
|
||||
if (
|
||||
@@ -981,8 +981,9 @@ export function WorkflowView({
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
// Determine which ITEM (specific run) owns this text
|
||||
const itemId = currentStreamingItemId.current;
|
||||
// Use the item_id from the event itself (for concurrent workflows)
|
||||
// Fall back to currentStreamingItemId for backwards compatibility
|
||||
const itemId = openAIEvent.item_id || currentStreamingItemId.current;
|
||||
|
||||
if (itemId) {
|
||||
// Initialize item output if needed
|
||||
@@ -1090,7 +1091,7 @@ export function WorkflowView({
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any, // OpenAI Responses API format, cast to satisfy TypeScript
|
||||
] as unknown as Record<string, unknown>, // OpenAI Responses API format, cast to satisfy RunWorkflowRequest type
|
||||
conversation_id: currentSession?.conversation_id || undefined,
|
||||
checkpoint_id: selectedCheckpointId || undefined, // Pass selected checkpoint
|
||||
};
|
||||
@@ -1101,7 +1102,12 @@ export function WorkflowView({
|
||||
request
|
||||
);
|
||||
|
||||
// Track if new HIL requests arrive during response processing
|
||||
let newHilRequestsArrived = false;
|
||||
const newHilRequests: typeof pendingHilRequests = [];
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
|
||||
// Store workflow-related events
|
||||
if (
|
||||
openAIEvent.type === "response.output_item.added" ||
|
||||
@@ -1117,7 +1123,7 @@ export function WorkflowView({
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
? (prev[prev.length - 1] as any)._uiTimestamp || 0
|
||||
? (prev[prev.length - 1] as { _uiTimestamp?: number })._uiTimestamp || 0
|
||||
: 0;
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
@@ -1134,9 +1140,50 @@ export function WorkflowView({
|
||||
// Pass to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Check for new HIL requests after sending responses - handles multi-round HIL
|
||||
if (openAIEvent.type === "response.request_info.requested") {
|
||||
const hilEvent = openAIEvent as ResponseRequestInfoEvent;
|
||||
newHilRequestsArrived = true;
|
||||
|
||||
// Cast to the correct type for setPendingHilRequests
|
||||
const typedHilEvent = {
|
||||
request_id: hilEvent.request_id,
|
||||
request_data: hilEvent.request_data,
|
||||
request_schema: hilEvent.request_schema as unknown as JSONSchemaProperty,
|
||||
};
|
||||
|
||||
// Collect new requests (don't update state yet)
|
||||
newHilRequests.push(typedHilEvent);
|
||||
|
||||
// Initialize response data with defaults from schema
|
||||
const schema = hilEvent.request_schema as unknown as JSONSchemaProperty;
|
||||
const defaultValues: Record<string, unknown> = {};
|
||||
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(
|
||||
([fieldName, fieldSchema]) => {
|
||||
const field = fieldSchema as JSONSchemaProperty;
|
||||
// Set default for enum fields to first option
|
||||
if (field.enum && field.enum.length > 0) {
|
||||
defaultValues[fieldName] = field.enum[0];
|
||||
}
|
||||
// Use explicit default value if provided
|
||||
else if (field.default !== undefined) {
|
||||
defaultValues[fieldName] = field.default;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setHilResponses((prev) => ({
|
||||
...prev,
|
||||
[hilEvent.request_id]: defaultValues,
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle workflow output items (from ctx.yield_output)
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const item = (openAIEvent as any).item;
|
||||
const item = (openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent).item;
|
||||
|
||||
// Handle executor action items
|
||||
if (
|
||||
@@ -1205,10 +1252,28 @@ export function WorkflowView({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear HIL state after successful submission
|
||||
setPendingHilRequests([]);
|
||||
setHilResponses({});
|
||||
setIsStreaming(false);
|
||||
// Handle cleanup based on whether new HIL requests arrived
|
||||
if (newHilRequestsArrived) {
|
||||
// Replace old pending requests with new ones
|
||||
setPendingHilRequests(newHilRequests);
|
||||
|
||||
// Clear old responses and keep only new ones
|
||||
const newResponsesMap: Record<string, Record<string, unknown>> = {};
|
||||
newHilRequests.forEach(req => {
|
||||
if (hilResponses[req.request_id]) {
|
||||
newResponsesMap[req.request_id] = hilResponses[req.request_id];
|
||||
}
|
||||
});
|
||||
setHilResponses(newResponsesMap);
|
||||
|
||||
setShowHilModal(true);
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
// No new requests - clear HIL state
|
||||
setPendingHilRequests([]);
|
||||
setHilResponses({});
|
||||
setIsStreaming(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error will be displayed in timeline
|
||||
console.error("HIL submission error:", error);
|
||||
@@ -1299,26 +1364,25 @@ export function WorkflowView({
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={
|
||||
isReloading || selectedWorkflow.metadata?.source === "in_memory"
|
||||
}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
selectedWorkflow.metadata?.source === "in_memory"
|
||||
? "In-memory entities cannot be reloaded"
|
||||
: isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
{/* Only show reload button for directory-based entities */}
|
||||
{selectedWorkflow.source !== "in_memory" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReloadEntity}
|
||||
disabled={isReloading}
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
title={
|
||||
isReloading
|
||||
? "Reloading..."
|
||||
: "Reload entity code (hot reload)"
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Session & Checkpoint Controls - Compact header like agent view */}
|
||||
|
||||
@@ -335,6 +335,24 @@ export function SettingsModal({
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Settings */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Show Tool Calls
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display function/tool calls and results in chat messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useDevUIStore.getState().showToolCalls}
|
||||
onCheckedChange={(checked) => useDevUIStore.getState().setShowToolCalls(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -35,16 +35,38 @@ interface DialogFooterProps {
|
||||
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
const handleBackdropClick = () => {
|
||||
// Close the modal when backdrop is clicked
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
{/* Modal content */}
|
||||
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
||||
const handleContentClick = (e: React.MouseEvent) => {
|
||||
// Stop any clicks inside the content from bubbling to backdrop
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleContentMouseDown = (e: React.MouseEvent) => {
|
||||
// Prevent mousedown from bubbling during text selection
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop - handles clicks to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal content - positioned above backdrop with z-index */}
|
||||
<div
|
||||
className="relative z-10"
|
||||
onClick={handleContentClick}
|
||||
onMouseDown={handleContentMouseDown}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -601,7 +601,8 @@ class ApiClient {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
@@ -656,12 +657,12 @@ class ApiClient {
|
||||
} as ExtendedResponseStreamEvent;
|
||||
lastSequenceNumber = eventSeq;
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
|
||||
// Save new event to storage
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
// Skip events we've already seen (resume from last position)
|
||||
@@ -670,23 +671,23 @@ class ApiClient {
|
||||
} else {
|
||||
lastSequenceNumber = eventSeq;
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
|
||||
// Save event to storage before yielding
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
} else {
|
||||
// No sequence number - just yield the event
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
|
||||
// Still save to storage if we have conversation context
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -59,6 +59,7 @@ interface DevUIState {
|
||||
debugPanelWidth: number;
|
||||
debugEvents: ExtendedResponseStreamEvent[];
|
||||
isResizing: boolean;
|
||||
showToolCalls: boolean; // UI setting to show/hide tool calls in chat
|
||||
|
||||
// Modal Slice
|
||||
showAboutModal: boolean;
|
||||
@@ -143,6 +144,7 @@ interface DevUIActions {
|
||||
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
|
||||
clearDebugEvents: () => void;
|
||||
setIsResizing: (resizing: boolean) => void;
|
||||
setShowToolCalls: (show: boolean) => void;
|
||||
|
||||
// Modal Actions
|
||||
setShowAboutModal: (show: boolean) => void;
|
||||
@@ -224,6 +226,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
debugPanelWidth: 320,
|
||||
debugEvents: [],
|
||||
isResizing: false,
|
||||
showToolCalls: true, // Default to showing tool calls
|
||||
|
||||
// Modal State
|
||||
showAboutModal: false,
|
||||
@@ -365,6 +368,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
|
||||
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
|
||||
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
|
||||
setShowToolCalls: (show) => set({ showToolCalls: show }),
|
||||
addDebugEvent: (event) =>
|
||||
set((state) => {
|
||||
// Generate unique timestamp for each event
|
||||
@@ -597,6 +601,7 @@ export const useDevUIStore = create<DevUIStore>()(
|
||||
showDebugPanel: state.showDebugPanel,
|
||||
debugPanelMinimized: state.debugPanelMinimized,
|
||||
debugPanelWidth: state.debugPanelWidth,
|
||||
showToolCalls: state.showToolCalls, // Persist tool calls visibility preference
|
||||
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
|
||||
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
|
||||
}),
|
||||
|
||||
@@ -46,9 +46,10 @@ export interface ResponseCompletedEvent {
|
||||
type: "response.completed";
|
||||
response: {
|
||||
id: string;
|
||||
status: "completed";
|
||||
status?: "completed";
|
||||
usage?: any; // Optional usage information
|
||||
model?: string; // Optional model information
|
||||
[key: string]: any; // Allow any additional fields
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
@@ -299,9 +299,9 @@ async def test_agent_lifecycle_events(mapper: MessageMapper, test_request: Agent
|
||||
complete_event = AgentCompletedEvent()
|
||||
events = await mapper.convert_event(complete_event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.completed"
|
||||
assert events[0].response.status == "completed"
|
||||
# AgentCompletedEvent no longer emits response.completed to avoid duplicates
|
||||
# The server will emit the final response.completed with usage data
|
||||
assert len(events) == 0
|
||||
|
||||
# Test AgentFailedEvent
|
||||
error = Exception("Test error")
|
||||
@@ -347,9 +347,9 @@ async def test_workflow_lifecycle_events(mapper: MessageMapper, test_request: Ag
|
||||
complete_event = WorkflowCompletedEvent(workflow_id="test_workflow_123")
|
||||
events = await mapper.convert_event(complete_event, test_request)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "response.completed"
|
||||
assert events[0].response.status == "completed"
|
||||
# WorkflowCompletedEvent no longer emits response.completed to avoid duplicates
|
||||
# The server will emit the final response.completed with usage data
|
||||
assert len(events) == 0
|
||||
|
||||
# Test WorkflowFailedEvent with error info
|
||||
failed_event = WorkflowFailedEvent(workflow_id="test_workflow_123", error_info={"message": "Workflow failed"})
|
||||
|
||||
Reference in New Issue
Block a user