Files
agent-framework/python/packages/devui/frontend/src/utils/workflow-utils.ts
T
Victor Dibia 7a45929807 Python: .Net: Dotnet devui compatibility fixes (#2026)
* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

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

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

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

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

* add workflow HIL support, user mode, other fixes

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

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

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

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

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

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

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

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

* DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format

* Phase 1: Add /meta endpoint and fix workflow event naming for .NET DevUI compatibility

* additional fixes for .NET DevUI workflow visualization item ID tracking

**Problem:**
.NET DevUI was generating different item IDs for ExecutorInvokedEvent and
ExecutorCompletedEvent, causing only the first executor to highlight in the
workflow graph. Long executor names and error messages also broke UI layout.

**Changes:**
- Add ExecutorActionItemResource to match Python DevUI implementation
- Track item IDs per executor using dictionary in AgentRunResponseUpdateExtensions
- Reuse same item ID across invoked/completed/failed events for proper pairing
- Add truncateText() utility to workflow-utils.ts
- Truncate executor names to 35 chars in execution timeline
- Truncate error messages to 150 chars in workflow graph nodes

** Details:**
- ExecutorActionItemResource registered with JSON source generation context
- Dictionary cleaned up after executor completion/failure to prevent memory leaks
- Frontend item tracking by unique item.id supports multiple executor runs
- All changes follow existing codebase patterns and conventions

Tested with review-workflow showing correct executor highlighting and state
transitions for sequential and concurrent executors.

* format fixes, remove cors tests

* remove unecessary attributes

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Reuben Bond <reuben.bond@gmail.com>
2025-11-10 18:56:31 +00:00

709 lines
22 KiB
TypeScript

import { applySimpleLayout } from "./simple-layout";
import type { Node, Edge } from "@xyflow/react";
import type {
ExecutorNodeData,
ExecutorState,
} from "@/components/features/workflow/executor-node";
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
} from "@/types";
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Truncates text that exceeds the maximum length and appends ellipsis
* @param text - The text to truncate
* @param maxLength - Maximum length before truncation (default: 50)
* @param ellipsis - String to append when truncated (default: '...')
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
*
* @example
* truncateText('Hello World', 5) // 'Hello...'
* truncateText('Short', 10) // 'Short'
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
*/
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + ellipsis;
}
export interface WorkflowDumpExecutor {
id: string;
type: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
interface RawExecutorData {
type_?: string;
type?: string;
name?: string;
description?: string;
config?: Record<string, unknown>;
}
export interface WorkflowDumpConnection {
source: string;
target: string;
condition?: string;
}
export interface WorkflowDump {
executors?: WorkflowDumpExecutor[];
connections?: WorkflowDumpConnection[];
start_executor?: string;
end_executors?: string[];
[key: string]: unknown; // Allow for additional properties
}
export interface NodeUpdate {
nodeId: string;
state: ExecutorState;
data?: unknown;
error?: string;
timestamp: string;
}
/**
* Convert workflow dump data to React Flow nodes
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void,
layoutDirection?: "LR" | "TB"
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let executors: WorkflowDumpExecutor[];
let startExecutorId: string | undefined;
if (typedWorkflow) {
// Use typed workflow structure
executors = Object.values(typedWorkflow.executors).map((executor) => ({
id: executor.id,
type: executor.type,
name:
((executor as Record<string, unknown>).name as string) || executor.id,
description: (executor as Record<string, unknown>).description as string,
config: (executor as Record<string, unknown>).config as Record<
string,
unknown
>,
}));
startExecutorId = typedWorkflow.start_executor_id;
} else {
// Fall back to generic handling for backwards compatibility
executors = getExecutorsFromDump(workflowDump as Record<string, unknown>);
const workflowDumpRecord = workflowDump as Record<string, unknown>;
startExecutorId = workflowDumpRecord?.start_executor_id as
| string
| undefined;
}
if (!executors || !Array.isArray(executors) || executors.length === 0) {
console.warn(
"No executors found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const nodes = executors.map((executor) => ({
id: executor.id,
type: "executor",
position: { x: 0, y: 0 }, // Will be set by layout algorithm
data: {
executorId: executor.id,
executorType: executor.type,
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
layoutDirection: layoutDirection || "LR",
onNodeClick,
},
}));
return nodes;
}
/**
* Convert workflow dump data to React Flow edges
*/
export function convertWorkflowDumpToEdges(
workflowDump: Workflow | Record<string, unknown> | undefined
): Edge[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToEdges: workflowDump is undefined");
return [];
}
// Try to get typed workflow first, then fall back to generic handling
const typedWorkflow = getTypedWorkflow(workflowDump);
let connections: WorkflowDumpConnection[];
if (typedWorkflow) {
// Use typed workflow structure to extract connections from edge_groups
connections = [];
typedWorkflow.edge_groups.forEach((group) => {
group.edges.forEach((edge) => {
connections.push({
source: edge.source_id,
target: edge.target_id,
condition: edge.condition_name,
});
});
});
} else {
// Fall back to generic handling for backwards compatibility
connections = getConnectionsFromDump(
workflowDump as Record<string, unknown>
);
}
if (!connections || !Array.isArray(connections) || connections.length === 0) {
console.warn(
"No connections found in workflow dump. Available keys:",
Object.keys(workflowDump)
);
return [];
}
const edges = connections.map((connection) => ({
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: "default",
animated: false,
style: {
stroke: "#6b7280",
strokeWidth: 2,
},
}));
return edges;
}
/**
* Extract executors from workflow dump - handles different possible structures
*/
function getExecutorsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpExecutor[] {
// First check if executors is an object (like in the actual dump structure)
if (
workflowDump.executors &&
typeof workflowDump.executors === "object" &&
!Array.isArray(workflowDump.executors)
) {
const executorsObj = workflowDump.executors as Record<
string,
RawExecutorData
>;
return Object.entries(executorsObj).map(([id, executor]) => ({
id,
type: executor.type_ || executor.type || "executor",
name: executor.name || id,
description: executor.description,
config: executor.config,
}));
}
// Try different possible keys where executors might be stored as arrays
const possibleKeys = ["executors", "agents", "steps", "nodes"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpExecutor[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getExecutorsFromDump(workflowDump.config as Record<string, unknown>);
}
// Fallback: create executors from any object keys that look like executor IDs
const executors: WorkflowDumpExecutor[] = [];
Object.entries(workflowDump).forEach(([key, value]) => {
if (
typeof value === "object" &&
value !== null &&
("type" in value || "type_" in value)
) {
const rawExecutor = value as RawExecutorData;
executors.push({
id: key,
type: rawExecutor.type_ || rawExecutor.type || "executor",
name: rawExecutor.name || key,
description: rawExecutor.description,
config: rawExecutor.config,
});
}
});
return executors;
}
/**
* Extract connections from workflow dump - handles different possible structures
*/
function getConnectionsFromDump(
workflowDump: Record<string, unknown>
): WorkflowDumpConnection[] {
// Handle edge_groups structure (actual dump format)
if (workflowDump.edge_groups && Array.isArray(workflowDump.edge_groups)) {
const connections: WorkflowDumpConnection[] = [];
workflowDump.edge_groups.forEach((group: unknown) => {
if (typeof group === "object" && group !== null && "edges" in group) {
const edges = (group as { edges: unknown }).edges;
if (Array.isArray(edges)) {
edges.forEach((edge: unknown) => {
if (
typeof edge === "object" &&
edge !== null &&
"source_id" in edge &&
"target_id" in edge
) {
const edgeObj = edge as {
source_id: string;
target_id: string;
condition_name?: string;
};
connections.push({
source: edgeObj.source_id,
target: edgeObj.target_id,
condition: edgeObj.condition_name || undefined,
});
}
});
}
}
});
return connections;
}
// Try different possible keys where connections might be stored
const possibleKeys = ["connections", "edges", "transitions", "links"];
for (const key of possibleKeys) {
if (workflowDump[key] && Array.isArray(workflowDump[key])) {
return workflowDump[key] as WorkflowDumpConnection[];
}
}
// If no direct array, try to extract from nested structures
if (workflowDump.config && typeof workflowDump.config === "object") {
return getConnectionsFromDump(
workflowDump.config as Record<string, unknown>
);
}
return [];
}
/**
* Apply auto-layout to nodes using a lightweight algorithm
* Replaces dagre to eliminate 4.88MB lodash dependency
*/
export function applyDagreLayout(
nodes: Node<ExecutorNodeData>[],
edges: Edge[],
direction: "TB" | "LR" = "LR"
): Node<ExecutorNodeData>[] {
return applySimpleLayout(nodes, edges, direction);
}
/**
* Process workflow events and extract node updates
* Handles both standard OpenAI events and fallback workflow_event format
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[],
startExecutorId?: string
): Record<string, NodeUpdate> {
const nodeUpdates: Record<string, NodeUpdate> = {};
let hasWorkflowStarted = false;
// Track the latest item ID for each executor to handle multiple runs
const latestItemIds: Record<string, string> = {};
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const itemId = item.id;
// Track the latest item ID for this executor
if (event.type === "response.output_item.added") {
latestItemIds[executorId] = itemId;
}
// Only process this event if it's for the latest item ID of this executor
// This prevents older "done" events from overwriting newer "added" events
const isLatestItem = latestItemIds[executorId] === itemId;
if (!isLatestItem && event.type === "response.output_item.done") {
return; // Skip this old completion event
}
let state: ExecutorState = "pending";
let error: string | undefined;
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;
}
// Handle workflow event format
else if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
const eventData = data.data;
let state: ExecutorState = "pending";
let error: string | undefined;
// Map event types to executor states
if (eventType === "ExecutorInvokedEvent") {
state = "running";
} else if (eventType === "ExecutorCompletedEvent") {
state = "completed";
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
state = "failed";
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
state = "completed";
} else if (eventType === "WorkflowStartedEvent") {
// Mark that workflow has started - we'll set start node to running
hasWorkflowStarted = true;
}
// Update the node state (keep most recent update per executor)
if (executorId) {
nodeUpdates[executorId] = {
nodeId: executorId,
state,
data: eventData,
error,
timestamp: new Date().toISOString(),
};
}
}
});
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
// ONLY if it hasn't received any explicit executor events
// This prevents overwriting the actual state after the executor has run
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
// Additional check: only set to running if we don't have completion/failure events for this executor
// This prevents setting to "running" after the executor has already completed
const hasCompletionEvent = events.some((event) => {
if (event.type === "response.output_item.done") {
const item = (event as any).item;
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
data.event_type?.includes("Error") ||
data.event_type?.includes("Failed"));
}
return false;
});
// Only set to running if the executor hasn't completed yet
if (!hasCompletionEvent) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
}
return nodeUpdates;
}
/**
* Update node states based on event processing
*/
export function updateNodesWithEvents(
nodes: Node<ExecutorNodeData>[],
nodeUpdates: Record<string, NodeUpdate>
): Node<ExecutorNodeData>[] {
return nodes.map((node) => {
const update = nodeUpdates[node.id];
if (update) {
return {
...node,
data: {
...node.data,
state: update.state,
outputData: update.data,
error: update.error,
// Preserve layoutDirection
layoutDirection: node.data.layoutDirection,
},
};
}
return node;
});
}
/**
* Get executors that are currently in execution (invoked but not yet completed)
*/
export function getCurrentlyExecutingExecutors(
events: ExtendedResponseStreamEvent[]
): string[] {
const executorTimeline: Record<
string,
{ lastEvent: string; timestamp: string }
> = {};
// Process events to find the most recent event for each executor
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
executorTimeline[executorId] = {
lastEvent: event.type === "response.output_item.added" ? "ExecutorInvokedEvent" : "ExecutorCompletedEvent",
timestamp: new Date().toISOString(),
};
}
}
// Handle workflow event format
else if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (
executorId &&
(eventType === "ExecutorInvokedEvent" ||
eventType === "ExecutorCompletedEvent")
) {
executorTimeline[executorId] = {
lastEvent: eventType,
timestamp: new Date().toISOString(),
};
}
}
});
// Find executors that were invoked but haven't completed yet
const currentlyExecuting = Object.entries(executorTimeline)
.filter(([, timeline]) => timeline.lastEvent === "ExecutorInvokedEvent")
.map(([executorId]) => executorId);
return currentlyExecuting;
}
/**
* Update edges with sequence-based animation
*/
export function updateEdgesWithSequenceAnalysis(
edges: Edge[],
events: ExtendedResponseStreamEvent[]
): Edge[] {
const currentlyExecuting = getCurrentlyExecutingExecutors(events);
// Build simple state tracking for each executor
const executorStates: Record<
string,
{ completed: boolean; invoked: boolean }
> = {};
events.forEach((event) => {
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const workflowEvent = event as ResponseWorkflowEventComplete;
const data = workflowEvent.data;
const executorId = data.executor_id;
const eventType = data.event_type;
if (executorId && eventType) {
if (!executorStates[executorId]) {
executorStates[executorId] = { completed: false, invoked: false };
}
if (eventType === "ExecutorInvokedEvent") {
executorStates[executorId].invoked = true;
} else if (eventType === "ExecutorCompletedEvent") {
executorStates[executorId].completed = true;
}
}
}
});
return edges.map((edge) => {
const sourceState = executorStates[edge.source];
const targetState = executorStates[edge.target];
const targetIsExecuting = currentlyExecuting.includes(edge.target);
let style = { ...edge.style };
let animated = false;
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#643FB2", // Purple accent
strokeWidth: 3,
strokeDasharray: "5,5",
};
animated = true;
}
// Completed edge: both source and target have completed
else if (sourceState?.completed && targetState?.completed) {
style = {
stroke: "#10b981", // Green
strokeWidth: 2,
};
}
// Invoked edge: source completed and target invoked (but not necessarily executing)
else if (sourceState?.completed && targetState?.invoked) {
style = {
stroke: "#f59e0b", // Orange
strokeWidth: 2,
};
}
// Default: Not traversed
else {
style = {
stroke: "#6b7280", // Gray
strokeWidth: 2,
};
}
return {
...edge,
style,
animated,
};
});
}
/**
* Consolidate bidirectional edges into single edges with arrows on both ends
* This reduces visual clutter when edges go in both directions between nodes
*
* Smart handle selection algorithm:
* The current implementation keeps whichever edge was encountered first in the array.
* Since edges are typically created in workflow definition order (following the primary flow),
* this naturally keeps the "forward" edge and discards the "backward" one.
*
* For example, if the workflow defines:
* 1. coordinator → planner (primary flow)
* 2. planner → coordinator (feedback loop)
*
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
* the natural output→input handle connection of the primary flow direction.
*
* React Flow will automatically route the edge to avoid overlaps, and the
* bidirectional arrows indicate that communication flows both ways.
*/
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
const edgeMap = new Map<string, Edge>();
const bidirectionalKeys = new Set<string>();
edges.forEach(edge => {
const forwardKey = `${edge.source}-${edge.target}`;
const reverseKey = `${edge.target}-${edge.source}`;
// Check if we already have the reverse edge
if (edgeMap.has(reverseKey)) {
// Mark both keys as bidirectional
bidirectionalKeys.add(reverseKey);
bidirectionalKeys.add(forwardKey);
// Update the existing reverse edge to be bidirectional
const existingEdge = edgeMap.get(reverseKey)!;
// Keep the existing edge's handles (they follow the primary workflow direction)
// Add bidirectional arrows to show two-way communication
edgeMap.set(reverseKey, {
...existingEdge,
markerStart: {
type: 'arrow' as const,
width: 20,
height: 20,
},
markerEnd: {
type: 'arrow' as const,
width: 20,
height: 20,
},
data: {
...existingEdge.data,
isBidirectional: true,
},
});
} else if (!bidirectionalKeys.has(forwardKey)) {
// Only add if this isn't the reverse of a bidirectional pair
edgeMap.set(forwardKey, edge);
}
});
return Array.from(edgeMap.values());
}