mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: DevUI Fix Serialization, Timestamp and Other Issues (#1584)
* refactor(devui): adopt standard OpenAI lifecycle events for agents and workflows - Replace custom workflow events with OpenAI Responses API standard lifecycle events - Add AgentStartedEvent, AgentCompletedEvent, AgentFailedEvent for clean separation - Implement ExecutorActionItem for workflow executor tracking - Convert informational events to trace events to reduce noise - Update README mapper table with comprehensive event mappings - Maintain full backward compatibility with legacy events * fix(devui): resolve timestamp overwriting and Content serialization errors - Fix tool call timestamps being overwritten on each render (#1483) - Add recursive Content serialization to handle ChatMessage and nested objects (#1548) - Implement proper MCP tool cleanup on server shutdown - Add timestamp field to function_result.complete events - Enhance credential and client resource cleanup Fixes #1483, #1548 Partial improvements for #1476
This commit is contained in:
committed by
GitHub
Unverified
parent
064ee8afbe
commit
6b66a34609
@@ -241,6 +241,8 @@ export default function App() {
|
||||
|
||||
// Show error state if loading failed
|
||||
if (entityError) {
|
||||
const currentBackendUrl = apiClient.getBaseUrl();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
<AppHeader
|
||||
@@ -290,7 +292,7 @@ export default function App() {
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default:{" "}
|
||||
<span className="font-mono">http://localhost:8080</span>
|
||||
<span className="font-mono">{currentBackendUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+101
-30
@@ -408,46 +408,82 @@ export function WorkflowView({
|
||||
// This preserves the workflow's final output for display
|
||||
};
|
||||
|
||||
// Extract workflow events from OpenAI events for executor tracking
|
||||
// Extract workflow and output item events from OpenAI events for executor tracking
|
||||
const workflowEvents = useMemo(() => {
|
||||
return openAIEvents.filter(
|
||||
(event) => event.type === "response.workflow_event.complete"
|
||||
(event) =>
|
||||
event.type === "response.output_item.added" ||
|
||||
event.type === "response.output_item.done" ||
|
||||
event.type === "response.created" ||
|
||||
event.type === "response.in_progress" ||
|
||||
event.type === "response.completed" ||
|
||||
event.type === "response.failed" ||
|
||||
// Keep legacy support for older backends
|
||||
event.type === "response.workflow_event.complete"
|
||||
);
|
||||
}, [openAIEvents]);
|
||||
|
||||
// Extract executor history from workflow events (filter out workflow-level events)
|
||||
const executorHistory = useMemo(() => {
|
||||
return workflowEvents
|
||||
.filter((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
// Filter out workflow-level events (those without executor_id)
|
||||
// These include: WorkflowStartedEvent, WorkflowOutputEvent, WorkflowStatusEvent, etc.
|
||||
return data.executor_id != null;
|
||||
const history: Array<{
|
||||
executorId: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
status: "running" | "completed" | "error";
|
||||
}> = [];
|
||||
|
||||
workflowEvents.forEach((event) => {
|
||||
// Handle new standard OpenAI events
|
||||
if (
|
||||
event.type === "response.output_item.added" ||
|
||||
event.type === "response.output_item.done"
|
||||
) {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
history.push({
|
||||
executorId: item.executor_id,
|
||||
message:
|
||||
event.type === "response.output_item.added"
|
||||
? "Executor started"
|
||||
: item.status === "completed"
|
||||
? "Executor completed"
|
||||
: item.status === "failed"
|
||||
? "Executor failed"
|
||||
: "Executor processing",
|
||||
timestamp: new Date().toISOString(),
|
||||
status:
|
||||
item.status === "completed"
|
||||
? "completed"
|
||||
: item.status === "failed"
|
||||
? "error"
|
||||
: "running",
|
||||
});
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((event) => {
|
||||
if ("data" in event && event.data && typeof event.data === "object") {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
return {
|
||||
}
|
||||
// Legacy support for older backends
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object"
|
||||
) {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
if (data.executor_id != null) {
|
||||
history.push({
|
||||
executorId: String(data.executor_id),
|
||||
message: String(data.event_type || "Processing"),
|
||||
timestamp: String(data.timestamp || new Date().toISOString()),
|
||||
status: String(data.event_type || "").includes("Completed")
|
||||
? ("completed" as const)
|
||||
? "completed"
|
||||
: String(data.event_type || "").includes("Error")
|
||||
? ("error" as const)
|
||||
: ("running" as const),
|
||||
};
|
||||
? "error"
|
||||
: "running",
|
||||
});
|
||||
}
|
||||
return {
|
||||
executorId: "unknown",
|
||||
message: "Processing",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "running" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return history;
|
||||
}, [workflowEvents]);
|
||||
|
||||
// Track active executors
|
||||
@@ -525,16 +561,51 @@ export function WorkflowView({
|
||||
);
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
// Only store workflow events in state for performance
|
||||
// Text deltas are processed directly without state updates
|
||||
if (openAIEvent.type === "response.workflow_event.complete") {
|
||||
// Store workflow-related events for tracking
|
||||
if (
|
||||
openAIEvent.type === "response.output_item.added" ||
|
||||
openAIEvent.type === "response.output_item.done" ||
|
||||
openAIEvent.type === "response.created" ||
|
||||
openAIEvent.type === "response.in_progress" ||
|
||||
openAIEvent.type === "response.completed" ||
|
||||
openAIEvent.type === "response.failed" ||
|
||||
openAIEvent.type === "response.workflow_event.complete" // Legacy
|
||||
) {
|
||||
setOpenAIEvents((prev) => [...prev, openAIEvent]);
|
||||
}
|
||||
|
||||
// Pass to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Handle workflow events to track current executor
|
||||
// Handle new standard OpenAI events
|
||||
if (openAIEvent.type === "response.output_item.added") {
|
||||
const item = (openAIEvent as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
currentStreamingExecutor.current = item.executor_id;
|
||||
// Initialize output for this executor if not exists
|
||||
if (!executorOutputs.current[item.executor_id]) {
|
||||
executorOutputs.current[item.executor_id] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle workflow completion
|
||||
if (openAIEvent.type === "response.completed") {
|
||||
// Workflow completed successfully
|
||||
// Final output is already in workflowResult from text streaming
|
||||
}
|
||||
|
||||
// Handle workflow failure
|
||||
if (openAIEvent.type === "response.failed") {
|
||||
const error = (openAIEvent as any).response?.error;
|
||||
if (error) {
|
||||
setWorkflowError(
|
||||
typeof error === "string" ? error : JSON.stringify(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support for older backends
|
||||
if (
|
||||
openAIEvent.type === "response.workflow_event.complete" &&
|
||||
"data" in openAIEvent &&
|
||||
|
||||
@@ -116,6 +116,39 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get a stable timestamp for an event
|
||||
// Uses event's own timestamp fields if available
|
||||
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
|
||||
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
|
||||
if ('timestamp' in event && typeof event.timestamp === 'string') {
|
||||
return new Date(event.timestamp).toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Priority 2: Check for nested data.timestamp (workflow/trace events)
|
||||
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
|
||||
const dataTimestamp = (event.data as any).timestamp;
|
||||
if (typeof dataTimestamp === 'string') {
|
||||
return new Date(dataTimestamp).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Check for created_at in response object (lifecycle events)
|
||||
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
|
||||
const createdAt = (event.response as any).created_at;
|
||||
if (typeof createdAt === 'number') {
|
||||
return new Date(createdAt * 1000).toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use sequence number as label (better than showing same time for all)
|
||||
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
|
||||
return `#${event.sequence_number}`;
|
||||
}
|
||||
|
||||
// Last resort: hide timestamp by returning empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper function to accumulate OpenAI events into meaningful units
|
||||
function processEventsForDisplay(
|
||||
events: ExtendedResponseStreamEvent[]
|
||||
@@ -551,7 +584,7 @@ function EventItem({ event }: EventItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const Icon = getEventIcon(event.type);
|
||||
const colorClass = getEventColor(event.type);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const summary = getEventSummary(event);
|
||||
|
||||
// Determine if this event has expandable content
|
||||
@@ -1487,7 +1520,7 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
|
||||
}
|
||||
|
||||
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const timestamp = getEventTimestamp(event);
|
||||
|
||||
// Check if this is a function call or result event
|
||||
const isFunctionCall = event.type === "response.function_call.complete";
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* - Horizontal rules (---)
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
@@ -35,10 +35,10 @@ interface CodeBlockProps {
|
||||
*/
|
||||
function CodeBlock({ code, language }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
@@ -397,9 +397,7 @@ class ApiClient {
|
||||
// Convert to OpenAI format - use model field for entity_id (same as agents)
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: workflowId, // Use workflow ID in model field (matches agent pattern)
|
||||
input: typeof request.input_data === 'string'
|
||||
? request.input_data
|
||||
: JSON.stringify(request.input_data || ""), // Convert input_data to string
|
||||
input: request.input_data || "", // Send dict directly, no stringification needed
|
||||
stream: true,
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
};
|
||||
|
||||
@@ -68,13 +68,13 @@ export type ResponseInputParam = ResponseInputItem[];
|
||||
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
|
||||
export interface AgentFrameworkExtraBody {
|
||||
entity_id: string;
|
||||
input_data?: Record<string, unknown>;
|
||||
// input_data removed - now using standard input field for all data
|
||||
}
|
||||
|
||||
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
|
||||
export interface AgentFrameworkRequest {
|
||||
model: string;
|
||||
input: string | ResponseInputParam; // Union type matching OpenAI
|
||||
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
|
||||
stream?: boolean;
|
||||
|
||||
// OpenAI conversation parameter (standard!)
|
||||
|
||||
@@ -104,11 +104,19 @@ export type {
|
||||
ResponseWorkflowEventComplete,
|
||||
ResponseTraceEventComplete,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseInProgressEvent,
|
||||
ResponseCompletedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionResultComplete,
|
||||
StructuredEvent,
|
||||
WorkflowItem,
|
||||
ExecutorActionItem,
|
||||
} from "./openai";
|
||||
|
||||
export { isExecutorAction } from "./openai";
|
||||
|
||||
// Re-export Agent Framework types
|
||||
export type {
|
||||
AgentFrameworkRequest,
|
||||
|
||||
@@ -21,6 +21,48 @@ export interface ResponseStreamEvent {
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
// Standard OpenAI Response Lifecycle Events
|
||||
export interface ResponseCreatedEvent {
|
||||
type: "response.created";
|
||||
response: {
|
||||
id: string;
|
||||
status: "in_progress";
|
||||
created_at: number;
|
||||
output?: any[];
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseInProgressEvent {
|
||||
type: "response.in_progress";
|
||||
response: {
|
||||
id: string;
|
||||
status: "in_progress";
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseCompletedEvent {
|
||||
type: "response.completed";
|
||||
response: {
|
||||
id: string;
|
||||
status: "completed";
|
||||
usage?: any; // Optional usage information
|
||||
model?: string; // Optional model information
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseFailedEvent {
|
||||
type: "response.failed";
|
||||
response: {
|
||||
id: string;
|
||||
status: "failed";
|
||||
error?: any;
|
||||
};
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// Custom Agent Framework OpenAI event types with structured data
|
||||
export interface ResponseWorkflowEventComplete {
|
||||
type: "response.workflow_event.complete";
|
||||
@@ -83,13 +125,41 @@ export interface ResponseFunctionToolCall {
|
||||
status?: "in_progress" | "completed" | "incomplete";
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Output Item Added Event
|
||||
// OpenAI standard: Output item added event
|
||||
// Workflow Item Types - flexible interface for any workflow item
|
||||
export interface WorkflowItem {
|
||||
type: string; // "executor_action", "workflow_action", "message", or any future type
|
||||
id: string;
|
||||
status?: "in_progress" | "completed" | "failed" | "cancelled";
|
||||
[key: string]: any; // Allow any additional fields
|
||||
}
|
||||
|
||||
// Executor Action Item (DevUI specific)
|
||||
export interface ExecutorActionItem extends WorkflowItem {
|
||||
type: "executor_action";
|
||||
executor_id: string;
|
||||
metadata?: Record<string, any>;
|
||||
result?: any;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
// Type guard for executor actions
|
||||
export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem {
|
||||
return item.type === "executor_action" && "executor_id" in item;
|
||||
}
|
||||
|
||||
// OpenAI Responses API - Output Item Events
|
||||
export interface ResponseOutputItemAddedEvent {
|
||||
type: "response.output_item.added";
|
||||
item: ResponseFunctionToolCall;
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemDoneEvent {
|
||||
type: "response.output_item.done";
|
||||
item: WorkflowItem | ResponseFunctionToolCall | any;
|
||||
output_index: number;
|
||||
sequence_number?: number;
|
||||
}
|
||||
|
||||
// Trace event - matching actual backend output
|
||||
@@ -171,6 +241,7 @@ export interface ResponseFunctionResultComplete {
|
||||
item_id: string;
|
||||
output_index: number;
|
||||
sequence_number: number;
|
||||
timestamp?: string; // Optional ISO timestamp for UI display
|
||||
}
|
||||
|
||||
// DevUI Extension: Turn Separator (UI-only event for grouping)
|
||||
@@ -182,11 +253,15 @@ export interface TurnSeparatorEvent {
|
||||
|
||||
// Union type for all structured events
|
||||
export type StructuredEvent =
|
||||
| ResponseCreatedEvent
|
||||
| ResponseInProgressEvent
|
||||
| ResponseCompletedEvent
|
||||
| ResponseFailedEvent
|
||||
| ResponseWorkflowEventComplete
|
||||
| ResponseTraceEventComplete
|
||||
| ResponseTraceComplete
|
||||
| ResponseOutputItemAddedEvent
|
||||
| ResponseOutputItemDoneEvent
|
||||
| ResponseFunctionCallComplete
|
||||
| ResponseFunctionCallDelta
|
||||
| ResponseFunctionCallArgumentsDelta
|
||||
@@ -249,12 +324,6 @@ export interface ResponseUsage {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI standard: response.completed event
|
||||
export interface ResponseCompletedEvent {
|
||||
type: "response.completed";
|
||||
response: OpenAIResponse;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
// Request format for Agent Framework
|
||||
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
|
||||
|
||||
@@ -307,6 +307,7 @@ export function applyDagreLayout(
|
||||
|
||||
/**
|
||||
* Process workflow events and extract node updates
|
||||
* Handles both new standard OpenAI events and legacy workflow events
|
||||
*/
|
||||
export function processWorkflowEvents(
|
||||
events: ExtendedResponseStreamEvent[],
|
||||
@@ -316,7 +317,43 @@ export function processWorkflowEvents(
|
||||
let hasWorkflowStarted = false;
|
||||
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
|
||||
let state: ExecutorState = "pending";
|
||||
let error: string | undefined;
|
||||
|
||||
if (event.type === "response.output_item.added") {
|
||||
state = "running";
|
||||
} else if (event.type === "response.output_item.done") {
|
||||
if (item.status === "completed") {
|
||||
state = "completed";
|
||||
} else if (item.status === "failed") {
|
||||
state = "failed";
|
||||
error = item.error ? (typeof item.error === "string" ? item.error : JSON.stringify(item.error)) : "Execution failed";
|
||||
} else if (item.status === "cancelled") {
|
||||
state = "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
nodeUpdates[executorId] = {
|
||||
nodeId: executorId,
|
||||
state,
|
||||
data: item.result,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Handle workflow lifecycle events
|
||||
else if (event.type === "response.created" || event.type === "response.in_progress") {
|
||||
hasWorkflowStarted = true;
|
||||
}
|
||||
// Legacy support for older backends
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
@@ -417,7 +454,20 @@ export function getCurrentlyExecutingExecutors(
|
||||
|
||||
// Process events to find the most recent event for each executor
|
||||
events.forEach((event) => {
|
||||
if (
|
||||
// Handle new standard OpenAI events
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
const item = (event as any).item;
|
||||
if (item && item.type === "executor_action" && item.executor_id) {
|
||||
const executorId = item.executor_id;
|
||||
|
||||
executorTimeline[executorId] = {
|
||||
lastEvent: event.type === "response.output_item.added" ? "ExecutorInvokedEvent" : "ExecutorCompletedEvent",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Legacy support for older backends
|
||||
else if (
|
||||
event.type === "response.workflow_event.complete" &&
|
||||
"data" in event &&
|
||||
event.data
|
||||
|
||||
Reference in New Issue
Block a user