diff --git a/AGENTS.md b/AGENTS.md index 6e4541e..e20232d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,3 +47,8 @@ This repository is an npm workspace monorepo: - Prefer existing tokens/components from the submodule before building custom UI in `apps/web`. - If a needed component does not exist in `design-system/`, pause and consult the user before adding a new component or introducing a non-design-system alternative. - In a brand new git worktree, initialize submodules before development (`git submodule update --init --recursive`), or the design-system assets will be missing. + +## Subagent Graph Rules +- Subagent links are tool-delegation edges and do not represent execution flow edges. +- Subagent hierarchies must remain acyclic (for example, `A -> B -> A` is invalid). +- Subagent targets are tool-only nodes and cannot participate in regular execution edges. diff --git a/CLAUDE.md b/CLAUDE.md index 992f81f..db2645e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,8 @@ npm run build:packages # Build only shared packages (types + engine) **Agent backend:** Server agent execution is implemented through OpenAI Agents SDK (`apps/server/src/services/openai-agents-llm.ts`). Agent runs are capped with `maxTurns: 20` to bound loop iterations. +**Subagent hierarchy:** Agent nodes may expose other agent nodes as tools through subagent links. These links are not execution-flow edges. The subagent graph must be acyclic, and subagent targets are tool-only (no regular execution edges). + **Build dependency chain:** `packages/types` → `packages/workflow-engine` → `apps/server` / `apps/web`. Always run `build:packages` before typechecking or building apps. ## Design System (Git Submodule) diff --git a/README.md b/README.md index 73f582e..609b0b4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ data/ - **Visual Editor** – Canvas, floating palette, zoom controls, and inline node forms for prompts, branching rules, and approval copy. - **Run Console** – Chat-style panel that renders agent responses progressively as they arrive via SSE, with per-agent labels, spinner states, and approval requests. - **Workflow Engine** – Handles graph traversal, approvals, and LLM invocation (OpenAI Agents SDK). +- **Subagent Tools** – Agent nodes can expose connected agent nodes as nested subagent tools; these links are tool-delegation edges, not workflow execution edges. - **Persistent Audit Trail** – Every run writes `data/runs/run_.json` containing the workflow graph plus raw execution logs, independent of what the UI chooses to display. ## Getting Started @@ -65,6 +66,7 @@ Workflow run preflight rules and blocking conditions are documented in `apps/web ## Architecture Notes - **`@agentic/workflow-engine`**: Pure TypeScript package that normalizes graphs, manages state, pauses for approvals, and calls an injected `WorkflowLLM`. It now exposes `getGraph()` so callers can persist what actually ran. +- **Subagent hierarchy**: Subagent connectors form an acyclic parent/child graph among Agent nodes (`A -> B -> C` allowed, `A -> B -> A` blocked). Subagent targets are tool-only and excluded from execution-flow traversal. - **Server (`apps/server`)**: Five Express routes. `/api/run-stream` is the primary execution path — streams each `WorkflowLogEntry` as an SSE event so the UI renders progressively. `/api/run` is the same execution as a single JSON response. `/api/resume` continues paused approval workflows. `/api/config` serves `.config/config.json` (provider/model definitions). `/api/default-workflow` serves `.config/default-workflow.json` if present. - **Web (`apps/web`)**: Vite SPA using the CodeSignal design system. Core UI logic lives in `src/app/workflow-editor.ts`; shared helpers (help modal, API client, etc.) live under `src/`. - **Shared contracts**: `packages/types` keeps node shapes, graph schemas, log formats, and run-record definitions in sync across the stack. diff --git a/apps/server/src/services/openai-agents-llm.ts b/apps/server/src/services/openai-agents-llm.ts index 37f02bc..c2ef979 100644 --- a/apps/server/src/services/openai-agents-llm.ts +++ b/apps/server/src/services/openai-agents-llm.ts @@ -1,17 +1,120 @@ -import type { AgentInvocation, WorkflowLLM } from '@agentic/workflow-engine'; +import type { + AgentInvocation, + AgentRespondOptions, + AgentRuntimeEvent, + AgentSubagentInvocation, + AgentToolsConfig, + WorkflowLLM +} from '@agentic/workflow-engine'; -type AgentsSdkModule = { - Agent: new (config: Record) => unknown; +type ToolCallDetails = { + toolCall?: { + callId?: unknown; + id?: unknown; + }; +}; + +type AgentsSdkTool = { + name?: unknown; +}; + +type AgentsSdkAgent = { + asTool: (options: { + toolName?: string; + toolDescription?: string; + runOptions?: { maxTurns?: number }; + }) => AgentsSdkTool; + on( + event: 'agent_start', + listener: (context: unknown, agent: AgentsSdkAgent) => void + ): void; + on( + event: 'agent_end', + listener: (context: unknown, output: string) => void + ): void; + on( + event: 'agent_tool_start', + listener: (context: unknown, tool: AgentsSdkTool, details: ToolCallDetails) => void + ): void; + on( + event: 'agent_tool_end', + listener: ( + context: unknown, + tool: AgentsSdkTool, + result: string, + details: ToolCallDetails + ) => void + ): void; +}; + +type AgentsSdkRunner = { + on( + event: 'agent_start', + listener: (context: unknown, agent: AgentsSdkAgent) => void + ): void; + on( + event: 'agent_end', + listener: (context: unknown, agent: AgentsSdkAgent, output: string) => void + ): void; + on( + event: 'agent_tool_start', + listener: ( + context: unknown, + agent: AgentsSdkAgent, + tool: AgentsSdkTool, + details: ToolCallDetails + ) => void + ): void; + on( + event: 'agent_tool_end', + listener: ( + context: unknown, + agent: AgentsSdkAgent, + tool: AgentsSdkTool, + result: string, + details: ToolCallDetails + ) => void + ): void; run: ( - agent: unknown, + agent: AgentsSdkAgent, input: string, options?: { maxTurns?: number } ) => Promise<{ finalOutput?: unknown }>; - webSearchTool: () => unknown; +}; + +type AgentsSdkModule = { + Agent: new (config: Record) => AgentsSdkAgent; + Runner: new () => AgentsSdkRunner; + webSearchTool: () => AgentsSdkTool; +}; + +type AgentNodeMeta = { + nodeId: string; + agentName: string; + parentNodeId?: string; +}; + +type AgentContextState = { + nodeId: string; + depth: number; + activeCallId?: string; +}; + +type PendingChildCall = { + callId: string; + depth: number; +}; + +type AgentBuildRegistry = { + toolRegistry: Map; + agentRegistry: WeakMap; + agentsByNodeId: Map; }; let sdkModulePromise: Promise | null = null; const MAX_AGENT_TURNS = 20; +const MAX_SUBAGENT_PROMPT_CHARS_IN_DESCRIPTION = 280; +const MAX_NESTED_SUBAGENT_NAMES_IN_DESCRIPTION = 4; async function loadAgentsSdk(): Promise { if (!sdkModulePromise) { @@ -28,13 +131,175 @@ async function loadAgentsSdk(): Promise { return sdkModulePromise; } -function buildAgentTools(invocation: AgentInvocation, sdk: AgentsSdkModule): unknown[] { - const tools: unknown[] = []; +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +function toToolName(agentName: string, nodeId: string): string { + const nameSlug = agentName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + const fallbackName = nameSlug || 'agent'; + const nodeSlug = nodeId.replace(/[^a-zA-Z0-9_]+/g, '_'); + return `subagent_${fallbackName}_${nodeSlug}`.toLowerCase(); +} + +function getAgentName(invocation: AgentInvocation | AgentSubagentInvocation): string { + if ( + 'agentName' in invocation && + typeof invocation.agentName === 'string' && + invocation.agentName.trim() + ) { + return invocation.agentName.trim(); + } + return 'Workflow Agent'; +} + +function getToolName(tool: AgentsSdkTool): string | null { + return typeof tool.name === 'string' && tool.name.trim() ? tool.name : null; +} + +function getToolCallId(details: ToolCallDetails): string | null { + const callId = details.toolCall?.callId; + if (typeof callId === 'string' && callId.trim()) { + return callId; + } + const id = details.toolCall?.id; + if (typeof id === 'string' && id.trim()) { + return id; + } + return null; +} + +function formatToolKeyLabel(toolKey: string): string { + if (toolKey === 'web_search') return 'Web Search'; + if (toolKey === 'subagents') return 'Subagents'; + return toolKey + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function getEnabledToolLabels(tools: AgentToolsConfig | undefined): string[] { + if (!tools) return []; + return Object.entries(tools) + .filter(([, enabled]) => Boolean(enabled)) + .map(([toolKey]) => formatToolKeyLabel(toolKey)); +} + +function summarizeSystemPrompt(systemPrompt: string): string { + const normalized = systemPrompt.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return 'No system prompt provided.'; + } + if (normalized.length <= MAX_SUBAGENT_PROMPT_CHARS_IN_DESCRIPTION) { + return normalized; + } + return `${normalized.slice(0, MAX_SUBAGENT_PROMPT_CHARS_IN_DESCRIPTION).trimEnd()}…`; +} + +function summarizeNestedSubagents(subagents: AgentSubagentInvocation[] | undefined): string { + const nested = subagents ?? []; + if (nested.length === 0) { + return 'None'; + } + + const names = nested.map((entry) => (entry.agentName || 'Agent').trim() || 'Agent'); + if (names.length <= MAX_NESTED_SUBAGENT_NAMES_IN_DESCRIPTION) { + return names.join(', '); + } + + const visibleNames = names.slice(0, MAX_NESTED_SUBAGENT_NAMES_IN_DESCRIPTION).join(', '); + const remaining = names.length - MAX_NESTED_SUBAGENT_NAMES_IN_DESCRIPTION; + return `${visibleNames}, +${remaining} more`; +} + +function buildSubagentToolDescription( + subagentName: string, + subagent: AgentSubagentInvocation +): string { + const toolLabels = getEnabledToolLabels(subagent.tools); + const toolSummary = toolLabels.length > 0 ? toolLabels.join(', ') : 'None'; + const promptSummary = summarizeSystemPrompt(subagent.systemPrompt); + const nestedSubagentSummary = summarizeNestedSubagents(subagent.subagents); + return `Delegates work to subagent ${subagentName}. System prompt: ${promptSummary} Available tools: ${toolSummary}. Nested subagents: ${nestedSubagentSummary}.`; +} + +function buildSdkAgent( + invocation: AgentInvocation | AgentSubagentInvocation, + sdk: AgentsSdkModule, + nodeId: string, + registry: AgentBuildRegistry, + ancestry: Set = new Set() +): AgentsSdkAgent { + if (ancestry.has(nodeId)) { + throw new Error(`Subagent cycle detected while building SDK agent tree at "${nodeId}".`); + } + + const nextAncestry = new Set(ancestry); + nextAncestry.add(nodeId); + const tools = buildAgentTools(invocation, sdk, registry, nodeId, nextAncestry); + const agentName = getAgentName(invocation); + + const agentConfig: Record = { + name: agentName, + instructions: invocation.systemPrompt, + model: invocation.model + }; + + if (tools.length > 0) { + agentConfig.tools = tools; + } + + if (invocation.reasoningEffort) { + agentConfig.modelSettings = { + reasoning: { + effort: invocation.reasoningEffort + } + }; + } + + const agent = new sdk.Agent(agentConfig); + registry.agentRegistry.set(agent as object, { nodeId, agentName }); + registry.agentsByNodeId.set(nodeId, agent); + return agent; +} + +function buildAgentTools( + invocation: AgentInvocation | AgentSubagentInvocation, + sdk: AgentsSdkModule, + registry: AgentBuildRegistry, + parentNodeId: string, + ancestry: Set +): AgentsSdkTool[] { + const tools: AgentsSdkTool[] = []; if (invocation.tools?.web_search) { tools.push(sdk.webSearchTool()); } + const subagents = invocation.subagents ?? []; + for (const subagent of subagents) { + const childAgent = buildSdkAgent(subagent, sdk, subagent.nodeId, registry, ancestry); + const childAgentName = (subagent.agentName || 'Agent').trim() || 'Agent'; + const toolName = toToolName(childAgentName, subagent.nodeId); + registry.toolRegistry.set(toolName, { + nodeId: subagent.nodeId, + agentName: childAgentName, + parentNodeId + }); + + tools.push( + childAgent.asTool({ + toolName, + toolDescription: buildSubagentToolDescription(childAgentName, subagent), + runOptions: { maxTurns: MAX_AGENT_TURNS } + }) + ); + } + return tools; } @@ -48,30 +313,214 @@ function toTextOutput(finalOutput: unknown): string { return JSON.stringify(finalOutput); } +function emitRuntimeEvent( + options: AgentRespondOptions | undefined, + event: AgentRuntimeEvent +): void { + options?.onRuntimeEvent?.(event); +} + export class OpenAIAgentsLLMService implements WorkflowLLM { - async respond(invocation: AgentInvocation): Promise { + async respond( + invocation: AgentInvocation, + options?: AgentRespondOptions + ): Promise { const sdk = await loadAgentsSdk(); - const tools = buildAgentTools(invocation, sdk); - const agentConfig: Record = { - name: 'Workflow Agent', - instructions: invocation.systemPrompt, - model: invocation.model + const rootNodeId = options?.parentNodeId || '__root__'; + + const registry: AgentBuildRegistry = { + toolRegistry: new Map(), + agentRegistry: new WeakMap(), + agentsByNodeId: new Map() }; - if (tools.length > 0) { - agentConfig.tools = tools; - } + const agent = buildSdkAgent(invocation, sdk, rootNodeId, registry); + const runner = new sdk.Runner(); + + const pendingChildCallsByNodeId = new Map(); + const contextStateByContext = new WeakMap(); + const activeSubagentCalls = new Map(); + const activeSubagentCallIdsByNodeId = new Map(); + const generatedCallIdsByToolName = new Map(); + let syntheticCallCounter = 0; + + const getSyntheticCallId = (toolName: string): string => { + syntheticCallCounter += 1; + return `${toolName}_${syntheticCallCounter}`; + }; + + const getResolvedCallIdForToolEnd = ( + toolName: string, + subagentNodeId: string, + details: ToolCallDetails + ): string => { + const explicitCallId = getToolCallId(details); + if (explicitCallId) { + return explicitCallId; + } + + const generatedCallIds = generatedCallIdsByToolName.get(toolName); + if (generatedCallIds && generatedCallIds.length > 0) { + const nextGeneratedCallId = generatedCallIds.shift(); + if (generatedCallIds.length === 0) { + generatedCallIdsByToolName.delete(toolName); + } + if (nextGeneratedCallId) { + return nextGeneratedCallId; + } + } + + const activeCallIdsForNode = activeSubagentCallIdsByNodeId.get(subagentNodeId); + if (activeCallIdsForNode && activeCallIdsForNode.length > 0) { + return activeCallIdsForNode[activeCallIdsForNode.length - 1]; + } + + return getSyntheticCallId(toolName); + }; + + const handleAgentStart = (nodeId: string, context: unknown): void => { + if (!isObject(context)) return; + + const pendingQueue = pendingChildCallsByNodeId.get(nodeId); + const pendingCall = pendingQueue?.shift(); + if (pendingQueue && pendingQueue.length === 0) { + pendingChildCallsByNodeId.delete(nodeId); + } - if (invocation.reasoningEffort) { - agentConfig.modelSettings = { - reasoning: { - effort: invocation.reasoningEffort + contextStateByContext.set(context, { + nodeId, + depth: pendingCall?.depth ?? 0, + activeCallId: pendingCall?.callId + }); + }; + + const handleAgentEnd = (context: unknown): void => { + if (!isObject(context)) return; + contextStateByContext.delete(context); + }; + + const handleAgentToolStart = ( + sourceNodeId: string, + context: unknown, + tool: AgentsSdkTool, + details: ToolCallDetails + ): void => { + const toolName = getToolName(tool); + if (!toolName) return; + + const subagentMeta = registry.toolRegistry.get(toolName); + if (!subagentMeta) return; + + const contextState = isObject(context) ? contextStateByContext.get(context) : undefined; + const explicitCallId = getToolCallId(details); + const callId = explicitCallId ?? getSyntheticCallId(toolName); + if (!explicitCallId) { + const generatedCallIds = generatedCallIdsByToolName.get(toolName) ?? []; + generatedCallIds.push(callId); + generatedCallIdsByToolName.set(toolName, generatedCallIds); + } + let parentCallId = contextState?.activeCallId; + let parentDepth = contextState?.depth; + + if (!parentCallId) { + const activeParentCalls = activeSubagentCallIdsByNodeId.get(sourceNodeId); + const fallbackParentCallId = activeParentCalls?.[activeParentCalls.length - 1]; + if (fallbackParentCallId) { + parentCallId = fallbackParentCallId; + parentDepth = activeSubagentCalls.get(fallbackParentCallId)?.depth; } + } + + const depth = (parentDepth ?? 0) + 1; + const event: AgentRuntimeEvent = { + type: 'subagent_call_start', + parentNodeId: rootNodeId, + subagentNodeId: subagentMeta.nodeId, + subagentName: subagentMeta.agentName, + callId, + parentCallId, + depth }; - } - const agent = new sdk.Agent(agentConfig); - const result = await sdk.run(agent, invocation.userContent, { maxTurns: MAX_AGENT_TURNS }); - return toTextOutput(result.finalOutput); + activeSubagentCalls.set(callId, event); + const activeCallIdsForNode = activeSubagentCallIdsByNodeId.get(subagentMeta.nodeId) ?? []; + activeCallIdsForNode.push(callId); + activeSubagentCallIdsByNodeId.set(subagentMeta.nodeId, activeCallIdsForNode); + emitRuntimeEvent(options, event); + + const pendingQueue = pendingChildCallsByNodeId.get(subagentMeta.nodeId) ?? []; + pendingQueue.push({ callId, depth }); + pendingChildCallsByNodeId.set(subagentMeta.nodeId, pendingQueue); + }; + + const handleAgentToolEnd = (tool: AgentsSdkTool, details: ToolCallDetails): void => { + const toolName = getToolName(tool); + if (!toolName) return; + + const subagentMeta = registry.toolRegistry.get(toolName); + if (!subagentMeta) return; + + const callId = getResolvedCallIdForToolEnd(toolName, subagentMeta.nodeId, details); + + const startEvent = activeSubagentCalls.get(callId); + const resolvedSubagentNodeId = startEvent?.subagentNodeId ?? subagentMeta.nodeId; + const activeCallIdsForNode = activeSubagentCallIdsByNodeId.get(resolvedSubagentNodeId); + if (activeCallIdsForNode && activeCallIdsForNode.length > 0) { + const callIndex = activeCallIdsForNode.lastIndexOf(callId); + if (callIndex >= 0) { + activeCallIdsForNode.splice(callIndex, 1); + } + if (activeCallIdsForNode.length === 0) { + activeSubagentCallIdsByNodeId.delete(resolvedSubagentNodeId); + } + } + + const event: AgentRuntimeEvent = { + type: 'subagent_call_end', + parentNodeId: rootNodeId, + subagentNodeId: resolvedSubagentNodeId, + subagentName: startEvent?.subagentName ?? subagentMeta.agentName, + callId, + parentCallId: startEvent?.parentCallId, + depth: startEvent?.depth ?? 1 + }; + + emitRuntimeEvent(options, event); + activeSubagentCalls.delete(callId); + }; + + registry.agentsByNodeId.forEach((registeredAgent, registeredNodeId) => { + registeredAgent.on('agent_start', (context) => { + handleAgentStart(registeredNodeId, context); + }); + registeredAgent.on('agent_end', (context) => { + handleAgentEnd(context); + }); + registeredAgent.on('agent_tool_start', (context, tool, details) => { + handleAgentToolStart(registeredNodeId, context, tool, details); + }); + registeredAgent.on('agent_tool_end', (_context, tool, _result, details) => { + handleAgentToolEnd(tool, details); + }); + }); + + try { + const result = await runner.run(agent, invocation.userContent, { + maxTurns: MAX_AGENT_TURNS + }); + return toTextOutput(result.finalOutput); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + activeSubagentCalls.forEach((activeCall) => { + emitRuntimeEvent(options, { + ...activeCall, + type: 'subagent_call_error', + message + }); + }); + activeSubagentCalls.clear(); + activeSubagentCallIdsByNodeId.clear(); + throw error instanceof Error ? error : new Error(message); + } } } diff --git a/apps/web/docs/run-readiness.md b/apps/web/docs/run-readiness.md index 66e56f7..f429ad2 100644 --- a/apps/web/docs/run-readiness.md +++ b/apps/web/docs/run-readiness.md @@ -21,6 +21,10 @@ The run button is disabled if any of these are true: - No `Start` node exists. - More than one `Start` node exists. - Any connection references a missing source or target node. +- Any subagent link is invalid (source/target must both be Agent nodes, source must have Subagents tool enabled, and target must use the input handle). +- Any agent is targeted as subagent by more than one parent. +- Any subagent target participates in regular execution edges (subagent targets are tool-only). +- Any subagent cycle exists (`A -> B -> A`, including longer loops). - The `Start` node has no outgoing connection. - Nothing is reachable after `Start` (for example only `Start`, or `Start` not connected to any executable node). - A reachable `Condition` node has neither a condition branch nor a `false` fallback branch. @@ -29,10 +33,11 @@ The run button is disabled if any of these are true: ## Explicitly Allowed These cases are currently allowed and do not block run: -- Circular connections (loops). +- Circular execution connections (non-subagent loops). - Unreachable/disconnected nodes not on the reachable path from `Start`. - `Condition` with only one branch connected (at least one is required). - `Approval` with only one branch connected (at least one is required). +- Nested subagent chains (`A -> B -> C`) as long as they remain acyclic and tool-only. ## Backend Runtime Constraint (Not a UI Preflight Rule) diff --git a/apps/web/index.html b/apps/web/index.html index 45384d2..ccd9d3c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -55,6 +55,7 @@

Nodes

+
diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 1163e4b..cdf9bc9 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -7,7 +7,8 @@ import { renderMarkdown, escapeHtml } from './markdown'; const EXPANDED_NODE_WIDTH = 420; const TOOLS_CONFIG: Array<{ key: string; label: string; iconClass: string }> = [ - { key: 'web_search', label: 'Web Search', iconClass: 'icon-globe' } + { key: 'web_search', label: 'Web Search', iconClass: 'icon-globe' }, + { key: 'subagents', label: 'Subagents', iconClass: 'icon-engineering-manager' } ]; const DEFAULT_NODE_WIDTH = 150; // Fallback if DOM not ready const DEFAULT_MODEL_OPTIONS = ['gpt-5', 'gpt-5-mini', 'gpt-5.1']; @@ -18,10 +19,13 @@ const DEFAULT_MODEL_EFFORTS: Record = { }; const IF_CONDITION_HANDLE_PREFIX = 'condition-'; const IF_FALLBACK_HANDLE = 'false'; +const SUBAGENT_HANDLE = 'subagent'; +const SUBAGENT_TARGET_HANDLE = 'subagent-target'; const IF_PORT_BASE_TOP = 45; const IF_PORT_STEP = 30; const IF_COLLAPSED_MULTI_CONDITION_PORT_TOP = 18; const IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP = 45; +const SUBAGENT_PORT_MIN_TOP = 42; const PREVIOUS_OUTPUT_TEMPLATE = '{{PREVIOUS_OUTPUT}}'; const GENERIC_AGENT_SPINNER_KEY = '__generic_agent_spinner__'; const IF_CONDITION_OPERATORS = [ @@ -84,6 +88,18 @@ type RunHistoryEntry = { content: string; }; +type SubagentRuntimeLogPayload = { + parentNodeId?: string; + subagentNodeId: string; + subagentName: string; + callId: string; + parentCallId?: string; + depth: number; + message?: string; +}; + +type SubagentCallStatus = 'running' | 'completed' | 'failed'; + type DropdownItem = { value: string; label: string; @@ -194,6 +210,12 @@ export class WorkflowEditor { private zoomValue: HTMLElement | null; + private canvasValidationMessage: HTMLElement | null; + + private canvasValidationTimeout: ReturnType | null; + + private pendingLayoutSyncFrame: number | null; + private workflowState: WorkflowState; private rightPanel: HTMLElement | null; @@ -202,6 +224,14 @@ export class WorkflowEditor { private pendingAgentMessageCounts: Map; + private subagentCallElements: Map; + + private subagentCallSpinnerKeys: Map; + + private spinnerSubagentCallIds: Map>; + + private subagentCallStatuses: Map; + private currentPrompt: string; private pendingApprovalRequest: ApprovalRequest | null; @@ -306,10 +336,17 @@ export class WorkflowEditor { this.cancelRunButton = document.getElementById('btn-cancel-run') as HTMLButtonElement | null; this.clearButton = document.getElementById('btn-clear') as HTMLButtonElement | null; this.zoomValue = document.getElementById('zoom-value'); + this.canvasValidationMessage = document.getElementById('canvas-validation-message'); + this.canvasValidationTimeout = null; + this.pendingLayoutSyncFrame = null; this.workflowState = 'idle'; this.rightPanel = document.getElementById('right-panel'); this.pendingAgentMessages = new Map(); this.pendingAgentMessageCounts = new Map(); + this.subagentCallElements = new Map(); + this.subagentCallSpinnerKeys = new Map(); + this.spinnerSubagentCallIds = new Map>(); + this.subagentCallStatuses = new Map(); this.currentPrompt = ''; this.pendingApprovalRequest = null; this.activeRunController = null; @@ -443,9 +480,18 @@ export class WorkflowEditor { width: '100%', onSelect }); + this.scheduleConnectionLayoutSync(); return dropdown; } + scheduleConnectionLayoutSync(): void { + if (this.pendingLayoutSyncFrame !== null) return; + this.pendingLayoutSyncFrame = window.requestAnimationFrame(() => { + this.pendingLayoutSyncFrame = null; + this.renderConnections(false); + }); + } + applyViewport() { if (this.canvasStage) { this.canvasStage.style.transform = `translate(${this.viewport.x}px, ${this.viewport.y}px) scale(${this.viewport.scale})`; @@ -517,6 +563,289 @@ export class WorkflowEditor { return Number.isInteger(parsedIndex) && parsedIndex >= 0 ? parsedIndex : null; } + isSubagentConnection(connection: WorkflowConnection): boolean { + return connection.sourceHandle === SUBAGENT_HANDLE; + } + + getSubagentConnections(connections: WorkflowConnection[] = this.connections): WorkflowConnection[] { + return connections.filter((connection) => this.isSubagentConnection(connection)); + } + + getExecutionConnections(connections: WorkflowConnection[] = this.connections): WorkflowConnection[] { + return connections.filter((connection) => !this.isSubagentConnection(connection)); + } + + isSubagentTargetNode(nodeId: string, connections: WorkflowConnection[] = this.connections): boolean { + return connections.some( + (connection) => + this.isSubagentConnection(connection) && + connection.target === nodeId + ); + } + + getSubagentTargetIds(connections: WorkflowConnection[] = this.connections): Set { + const targetIds = new Set(); + this.getSubagentConnections(connections).forEach((connection) => { + targetIds.add(connection.target); + }); + return targetIds; + } + + canNodeBecomeSubagentTarget( + nodeId: string, + connections: WorkflowConnection[] = this.connections + ): boolean { + const targetNode = this.nodes.find((node) => node.id === nodeId); + if (!targetNode || targetNode.type !== 'agent') { + return false; + } + + if (this.isSubagentTargetNode(nodeId, connections)) { + return false; + } + + const candidateParents = this.nodes.filter( + (node) => + node.type === 'agent' && + node.id !== nodeId && + Boolean(node.data?.tools?.subagents) + ); + + return candidateParents.some((parentNode) => { + const candidateConnection: WorkflowConnection = { + source: parentNode.id, + target: nodeId, + sourceHandle: SUBAGENT_HANDLE, + targetHandle: 'input' + }; + return !this.getConnectionValidationError(candidateConnection, connections); + }); + } + + getSubagentPortTop(node: EditorNode): number { + const nodeEl = document.getElementById(node.id); + const height = nodeEl?.offsetHeight ?? (node.data?.collapsed ? 96 : 220); + return Math.max(SUBAGENT_PORT_MIN_TOP, height - 6); + } + + getConnectionStartPoint(sourceNode: EditorNode, sourceHandle?: string): Point { + if (sourceHandle === SUBAGENT_HANDLE) { + return { + x: sourceNode.x + (this.getNodeWidth(sourceNode) / 2), + y: sourceNode.y + this.getSubagentPortTop(sourceNode) + 6 + }; + } + return { + x: sourceNode.x + this.getNodeWidth(sourceNode), + y: sourceNode.y + this.getOutputPortCenterYOffset(sourceNode, sourceHandle) + }; + } + + getConnectionEndPoint(targetNode: EditorNode, sourceHandle?: string): Point { + if (sourceHandle === SUBAGENT_HANDLE) { + return { + x: targetNode.x + (this.getNodeWidth(targetNode) / 2), + y: targetNode.y + }; + } + return { + x: targetNode.x, + y: targetNode.y + 24 + }; + } + + getSubagentGraphValidationError(connections: WorkflowConnection[] = this.connections): string | null { + const subagentConnections = this.getSubagentConnections(connections); + if (subagentConnections.length === 0) { + return null; + } + + const nodeById = new Map(this.nodes.map((node) => [node.id, node])); + const incomingSubagentCounts = new Map(); + const adjacency = new Map(); + + for (const connection of subagentConnections) { + const sourceNode = nodeById.get(connection.source); + const targetNode = nodeById.get(connection.target); + + if (!sourceNode || sourceNode.type !== 'agent') { + return 'Subagent links must start from an Agent node.'; + } + if (!targetNode || targetNode.type !== 'agent') { + return 'Subagent links must target an Agent node.'; + } + if (!sourceNode.data?.tools || !sourceNode.data.tools.subagents) { + return 'Enable Subagents on the parent agent before linking subagents.'; + } + if (connection.targetHandle && connection.targetHandle !== 'input') { + return 'Subagent links must connect to the target input handle.'; + } + if (connection.source === connection.target) { + return 'An agent cannot be a subagent of itself.'; + } + + incomingSubagentCounts.set( + connection.target, + (incomingSubagentCounts.get(connection.target) ?? 0) + 1 + ); + if ((incomingSubagentCounts.get(connection.target) ?? 0) > 1) { + return 'A subagent can belong to only one parent agent.'; + } + + const adjacent = adjacency.get(connection.source) ?? []; + adjacent.push(connection.target); + adjacency.set(connection.source, adjacent); + } + + const executionConnections = this.getExecutionConnections(connections); + for (const targetId of incomingSubagentCounts.keys()) { + const hasExecutionEdges = executionConnections.some( + (connection) => connection.source === targetId || connection.target === targetId + ); + if (hasExecutionEdges) { + return 'Subagent targets cannot be connected to regular workflow execution edges.'; + } + } + + const visitState = new Map(); + const visit = (nodeId: string, path: string[]): string | null => { + const state = visitState.get(nodeId); + if (state === 'visiting') { + const cycleStart = path.indexOf(nodeId); + const cyclePath = [...path.slice(cycleStart), nodeId].join(' -> '); + return `Subagent hierarchy must be acyclic. Cycle: ${cyclePath}`; + } + if (state === 'visited') return null; + + visitState.set(nodeId, 'visiting'); + const neighbors = adjacency.get(nodeId) ?? []; + for (const neighbor of neighbors) { + const error = visit(neighbor, [...path, neighbor]); + if (error) return error; + } + visitState.set(nodeId, 'visited'); + return null; + }; + + for (const nodeId of adjacency.keys()) { + const error = visit(nodeId, [nodeId]); + if (error) { + return error; + } + } + + return null; + } + + getConnectionValidationError( + nextConnection: WorkflowConnection, + connections: WorkflowConnection[] = this.connections + ): string | null { + const sourceNode = this.nodes.find((node) => node.id === nextConnection.source); + const targetNode = this.nodes.find((node) => node.id === nextConnection.target); + if (!sourceNode || !targetNode) { + return 'Connection source or target node is missing.'; + } + + const candidateConnections = [...connections, nextConnection]; + const nextIsSubagentConnection = this.isSubagentConnection(nextConnection); + + if (nextIsSubagentConnection) { + if (sourceNode.type !== 'agent') { + return 'Only agent nodes can define subagents.'; + } + if (!sourceNode.data?.tools || !sourceNode.data.tools.subagents) { + return 'Enable Subagents on the parent agent before adding subagent links.'; + } + if (targetNode.type !== 'agent') { + return 'Subagent links can only target agent nodes.'; + } + if (nextConnection.targetHandle !== 'input') { + return 'Subagent links must connect to the target input handle.'; + } + if (nextConnection.source === nextConnection.target) { + return 'An agent cannot be a subagent of itself.'; + } + } else { + if (this.isSubagentTargetNode(nextConnection.source, candidateConnections)) { + return 'Subagent targets cannot be used as sources in regular workflow edges.'; + } + if (this.isSubagentTargetNode(nextConnection.target, candidateConnections)) { + return 'Subagent targets cannot be used as targets in regular workflow edges.'; + } + } + + return this.getSubagentGraphValidationError(candidateConnections); + } + + removeOutgoingSubagentConnections(sourceNodeId: string): boolean { + const previousLength = this.connections.length; + this.connections = this.connections.filter( + (connection) => + !( + connection.source === sourceNodeId && + this.isSubagentConnection(connection) + ) + ); + return this.connections.length !== previousLength; + } + + applyConnectionToTarget(targetNodeId: string, targetHandle: string): void { + if (!this.connectionStart || this.connectionStart.nodeId === targetNodeId) { + return; + } + + const nextConnection: WorkflowConnection = { + source: this.connectionStart.nodeId, + target: targetNodeId, + sourceHandle: this.connectionStart.handle, + targetHandle + }; + const connected = this.applyPendingConnection(nextConnection); + + if (connected) { + this.reconnectingConnection = null; + this.renderConnections(); + } else if (this.reconnectingConnection !== null) { + this.reconnectingConnection = null; + this.renderConnections(); + } + + this.clearPendingConnectionDragState(); + this.updateRunButton(); + } + + applyPendingConnection(nextConnection: WorkflowConnection): boolean { + const duplicateExists = this.connections.some( + (conn: WorkflowConnection) => + conn.source === nextConnection.source && + conn.target === nextConnection.target && + conn.sourceHandle === nextConnection.sourceHandle && + conn.targetHandle === nextConnection.targetHandle + ); + if (duplicateExists) { + return false; + } + + const validationError = this.getConnectionValidationError(nextConnection); + if (validationError) { + this.setCanvasValidationMessage(validationError); + return false; + } + + this.setCanvasValidationMessage(null); + this.connections.push(nextConnection); + return true; + } + + clearPendingConnectionDragState(): void { + if (this.tempConnection) { + this.tempConnection.remove(); + } + this.connectionStart = null; + this.tempConnection = null; + } + getIfPortTop(index: number): number { return IF_PORT_BASE_TOP + (index * IF_PORT_STEP); } @@ -623,6 +952,10 @@ export class WorkflowEditor { } getOutputPortCenterYOffset(node: EditorNode, sourceHandle?: string): number { + if (sourceHandle === SUBAGENT_HANDLE) { + return this.getSubagentPortTop(node) + 6; + } + if (node.type === 'if') { if (this.shouldAggregateCollapsedIfPorts(node)) { if (sourceHandle === IF_FALLBACK_HANDLE) { @@ -670,6 +1003,27 @@ export class WorkflowEditor { } } + setCanvasValidationMessage(message: string | null): void { + if (this.canvasValidationTimeout !== null) { + clearTimeout(this.canvasValidationTimeout); + this.canvasValidationTimeout = null; + } + if (!this.canvasValidationMessage) return; + if (!message) { + this.canvasValidationMessage.textContent = ''; + this.canvasValidationMessage.classList.remove('visible'); + return; + } + this.canvasValidationMessage.textContent = message; + this.canvasValidationMessage.classList.add('visible'); + this.canvasValidationTimeout = setTimeout(() => { + if (!this.canvasValidationMessage) return; + this.canvasValidationMessage.textContent = ''; + this.canvasValidationMessage.classList.remove('visible'); + this.canvasValidationTimeout = null; + }, 4500); + } + isAbortError(error: unknown): boolean { if (!error) return false; if (error instanceof Error && error.name === 'AbortError') return true; @@ -710,9 +1064,15 @@ export class WorkflowEditor { return 'Fix broken connections before running.'; } + const subagentGraphError = this.getSubagentGraphValidationError(); + if (subagentGraphError) { + return subagentGraphError; + } + const startNode = startNodes[0]; + const executionConnections = this.getExecutionConnections(); const adjacency = new Map(); - this.connections.forEach((conn: any) => { + executionConnections.forEach((conn: any) => { if (!adjacency.has(conn.source)) adjacency.set(conn.source, []); adjacency.get(conn.source).push(conn); }); @@ -822,6 +1182,26 @@ export class WorkflowEditor { this.runHistory.push({ role: 'user', content: text }); } + clearSubagentSpinnerState(spinnerKey?: string): void { + if (spinnerKey) { + const callIds = this.spinnerSubagentCallIds.get(spinnerKey); + if (callIds) { + callIds.forEach((callId) => { + this.subagentCallElements.delete(callId); + this.subagentCallSpinnerKeys.delete(callId); + this.subagentCallStatuses.delete(callId); + }); + } + this.spinnerSubagentCallIds.delete(spinnerKey); + return; + } + + this.subagentCallElements.clear(); + this.subagentCallSpinnerKeys.clear(); + this.spinnerSubagentCallIds.clear(); + this.subagentCallStatuses.clear(); + } + showAgentSpinner(name?: string, nodeId?: string) { if (!this.chatMessages) return; const spinnerKey = nodeId || GENERIC_AGENT_SPINNER_KEY; @@ -846,6 +1226,7 @@ export class WorkflowEditor { body.appendChild(text); body.appendChild(dots); spinner.appendChild(body); + this.chatMessages.appendChild(spinner); this.chatMessages.scrollTop = this.chatMessages.scrollHeight; this.pendingAgentMessages.set(spinnerKey, spinner); @@ -863,12 +1244,14 @@ export class WorkflowEditor { if (!spinner) return; spinner.remove(); this.pendingAgentMessages.delete(nodeId); + this.clearSubagentSpinnerState(nodeId); return; } this.pendingAgentMessages.forEach((spinner) => spinner.remove()); this.pendingAgentMessages.clear(); this.pendingAgentMessageCounts.clear(); + this.clearSubagentSpinnerState(); } zoomCanvas(stepPercent: any) { @@ -970,19 +1353,13 @@ export class WorkflowEditor { if (!targetPort) { // Connection was already removed when we started reconnecting, just render this.renderConnections(); - } - // Clean up will happen in onPortMouseUp if we connected, or here if we didn't - if (!targetPort) { this.reconnectingConnection = null; - this.tempConnection.remove(); - this.tempConnection = null; - this.connectionStart = null; + this.clearPendingConnectionDragState(); } - } else if (this.tempConnection && !this.reconnectingConnection) { + // Clean up will happen in onPortMouseUp if we connected + } else if (this.tempConnection && this.reconnectingConnection === null) { // Normal connection creation cancelled - this.tempConnection.remove(); - this.tempConnection = null; - this.connectionStart = null; + this.clearPendingConnectionDragState(); } }); } @@ -1111,6 +1488,25 @@ export class WorkflowEditor { updated = true; } + if (node.type === 'agent') { + if (!node.data) node.data = {}; + const tools = (node.data.tools && typeof node.data.tools === 'object') + ? node.data.tools as Record + : {}; + const nextTools = { + web_search: Boolean(tools.web_search), + subagents: Boolean(tools.subagents) + }; + if ( + !node.data.tools || + node.data.tools.web_search !== nextTools.web_search || + node.data.tools.subagents !== nextTools.subagents + ) { + node.data.tools = nextTools; + updated = true; + } + } + if (node.type === 'if') { ifNodeIds.add(node.id); if (!node.data) node.data = {}; @@ -1277,7 +1673,7 @@ export class WorkflowEditor { userPrompt: '{{PREVIOUS_OUTPUT}}', model: 'gpt-5', reasoningEffort: 'low', - tools: { web_search: false }, + tools: { web_search: false, subagents: false }, collapsed: true }; case 'if': @@ -1359,6 +1755,12 @@ export class WorkflowEditor { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; el.dataset.nodeId = node.id; + const isSubagentTarget = node.type === 'agent' && this.isSubagentTargetNode(node.id); + const isSubagentCandidate = + node.type === 'agent' && + (isSubagentTarget || this.canNodeBecomeSubagentTarget(node.id)); + el.classList.toggle('subagent-target-node', isSubagentTarget); + el.classList.toggle('subagent-candidate-node', isSubagentCandidate); if (!node.data) node.data = {}; if (node.data.collapsed === undefined) { @@ -1595,39 +1997,48 @@ export class WorkflowEditor { // Input container.appendChild(buildLabel('Input')); - const userInputWrapper = document.createElement('div'); - userInputWrapper.className = 'prompt-highlight-wrapper'; - const userInputHighlight = document.createElement('div'); - userInputHighlight.className = 'prompt-highlight-backdrop'; - userInputHighlight.setAttribute('aria-hidden', 'true'); - const userInputHighlightContent = document.createElement('div'); - userInputHighlightContent.className = 'prompt-highlight-content'; - userInputHighlight.appendChild(userInputHighlightContent); - const userInput = document.createElement('textarea'); - userInput.className = 'input textarea-input prompt-highlight-input'; - userInput.placeholder = 'Use {{PREVIOUS_OUTPUT}} to include the previous node\'s output.'; - userInput.value = data.userPrompt ?? PREVIOUS_OUTPUT_TEMPLATE; - const syncUserPromptHighlight = () => { - userInputHighlightContent.innerHTML = this.getUserPromptHighlightHTML(userInput.value); - userInputHighlightContent.style.transform = `translate(${-userInput.scrollLeft}px, ${-userInput.scrollTop}px)`; - }; - syncUserPromptHighlight(); - userInput.addEventListener('focus', () => { - userInputWrapper.classList.add('is-editing'); - }); - userInput.addEventListener('blur', () => { - userInputWrapper.classList.remove('is-editing'); - syncUserPromptHighlight(); - }); - userInput.addEventListener('input', (e: any) => { - data.userPrompt = e.target.value; + const isSubagentTarget = this.isSubagentTargetNode(node.id); + if (isSubagentTarget) { + data.userPrompt = ''; + const helperText = document.createElement('div'); + helperText.className = 'subagent-input-lock-note'; + helperText.textContent = 'Input managed by parent agent.'; + container.appendChild(helperText); + } else { + const userInputWrapper = document.createElement('div'); + userInputWrapper.className = 'prompt-highlight-wrapper'; + const userInputHighlight = document.createElement('div'); + userInputHighlight.className = 'prompt-highlight-backdrop'; + userInputHighlight.setAttribute('aria-hidden', 'true'); + const userInputHighlightContent = document.createElement('div'); + userInputHighlightContent.className = 'prompt-highlight-content'; + userInputHighlight.appendChild(userInputHighlightContent); + const userInput = document.createElement('textarea'); + userInput.className = 'input textarea-input prompt-highlight-input'; + userInput.placeholder = 'Use {{PREVIOUS_OUTPUT}} to include the previous node\'s output.'; + userInput.value = data.userPrompt ?? PREVIOUS_OUTPUT_TEMPLATE; + const syncUserPromptHighlight = () => { + userInputHighlightContent.innerHTML = this.getUserPromptHighlightHTML(userInput.value); + userInputHighlightContent.style.transform = `translate(${-userInput.scrollLeft}px, ${-userInput.scrollTop}px)`; + }; syncUserPromptHighlight(); - this.scheduleSave(); - }); - userInput.addEventListener('scroll', syncUserPromptHighlight); - userInputWrapper.appendChild(userInputHighlight); - userInputWrapper.appendChild(userInput); - container.appendChild(userInputWrapper); + userInput.addEventListener('focus', () => { + userInputWrapper.classList.add('is-editing'); + }); + userInput.addEventListener('blur', () => { + userInputWrapper.classList.remove('is-editing'); + syncUserPromptHighlight(); + }); + userInput.addEventListener('input', (e: any) => { + data.userPrompt = e.target.value; + syncUserPromptHighlight(); + this.scheduleSave(); + }); + userInput.addEventListener('scroll', syncUserPromptHighlight); + userInputWrapper.appendChild(userInputHighlight); + userInputWrapper.appendChild(userInput); + container.appendChild(userInputWrapper); + } // Model container.appendChild(buildLabel('Model')); @@ -1691,6 +2102,14 @@ export class WorkflowEditor { if (!data.tools) data.tools = {}; data.tools[tool.key] = checkbox.checked; this.updatePreview(node); + if (tool.key === 'subagents') { + if (!checkbox.checked) { + this.removeOutgoingSubagentConnections(node.id); + } + this.refreshNodePorts(node); + this.renderConnections(); + this.updateRunButton(); + } }); const box = document.createElement('span'); @@ -1814,9 +2233,47 @@ export class WorkflowEditor { this.scheduleSave(); } + enforceSubagentTargetInputLocks(): void { + let changed = false; + this.nodes.forEach((node) => { + if (node.type !== 'agent') return; + if (!this.isSubagentTargetNode(node.id)) return; + if (!node.data) node.data = {}; + if ((node.data.userPrompt ?? '') !== '') { + node.data.userPrompt = ''; + changed = true; + } + }); + if (changed) { + this.scheduleSave(); + } + } + + refreshSelectedNodeForm(): void { + if (!this.selectedNodeId) return; + const selectedNode = this.nodes.find((node) => node.id === this.selectedNodeId); + if (!selectedNode) return; + const selectedEl = document.getElementById(selectedNode.id); + if (!selectedEl) return; + const body = selectedEl.querySelector('.node-body.node-form'); + if (!(body instanceof HTMLElement)) return; + this.renderNodeForm(selectedNode, body); + } + // --- PORTS & CONNECTIONS (Updated for Arrows) --- renderPorts(node: any, el: any) { + if (node.type === 'agent') { + el.appendChild( + this.createPort( + node.id, + SUBAGENT_TARGET_HANDLE, + 'port-subagent-target', + 'Subagent target' + ) + ); + } + if (node.type !== 'start') { const portIn = this.createPort(node.id, 'input', 'port-in'); el.appendChild(portIn); @@ -1872,6 +2329,18 @@ export class WorkflowEditor { ) ); } + } else if (node.type === 'agent') { + el.appendChild(this.createPort(node.id, 'output', 'port-out')); + if (node.data?.tools?.subagents) { + el.appendChild( + this.createPort( + node.id, + SUBAGENT_HANDLE, + 'port-subagent', + 'Subagent' + ) + ); + } } else if (node.type === 'approval') { el.appendChild(this.createPort(node.id, 'approve', 'port-out port-true', 'Approve')); el.appendChild(this.createPort(node.id, 'reject', 'port-out port-false', 'Reject')); @@ -1901,7 +2370,7 @@ export class WorkflowEditor { port.setAttribute('aria-disabled', 'true'); } - if (handle === 'input') { + if (handle === 'input' || handle === SUBAGENT_TARGET_HANDLE) { port.addEventListener('mouseup', (e: any) => this.onPortMouseUp(e, nodeId, handle)); } else if (connectable) { port.addEventListener('mousedown', (e: any) => this.onPortMouseDown(e, nodeId, handle)); @@ -1909,21 +2378,41 @@ export class WorkflowEditor { return port; } + getConnectionLineClass( + sourceHandle?: string, + options: { editable?: boolean; reconnecting?: boolean } = {} + ): string { + const classes = ['connection-line']; + if (options.editable) { + classes.push('editable'); + } + if (sourceHandle === SUBAGENT_HANDLE) { + classes.push('connection-line-subagent'); + } + if (options.reconnecting) { + classes.push('reconnecting'); + } + return classes.join(' '); + } + // --- CONNECTION LOGIC (Same as before but renders arrows via CSS) --- onPortMouseDown(e: any, nodeId: any, handle: any) { e.stopPropagation(); e.preventDefault(); if (!this.connectionsLayer) return; + this.setCanvasValidationMessage(null); const sourceNode = this.nodes.find((candidate: any) => candidate.id === nodeId); if (sourceNode && this.shouldAggregateCollapsedIfPorts(sourceNode) && this.getIfConditionIndexFromHandle(handle) !== null) { return; } - const world = this.screenToWorld(e.clientX, e.clientY); - this.connectionStart = { nodeId, handle, x: world.x, y: world.y }; + const startPoint = sourceNode + ? this.getConnectionStartPoint(sourceNode, handle) + : this.screenToWorld(e.clientX, e.clientY); + this.connectionStart = { nodeId, handle, x: startPoint.x, y: startPoint.y }; this.tempConnection = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - this.tempConnection.setAttribute('class', 'connection-line'); + this.tempConnection.setAttribute('class', this.getConnectionLineClass(handle)); this.tempConnection.setAttribute('d', `M ${this.connectionStart.x} ${this.connectionStart.y} L ${this.connectionStart.x} ${this.connectionStart.y}`); this.connectionsLayer.appendChild(this.tempConnection); } @@ -1932,43 +2421,45 @@ export class WorkflowEditor { if (!this.connectionStart) return; if (!this.tempConnection) return; const world = this.screenToWorld(e.clientX, e.clientY); - this.tempConnection.setAttribute('d', this.getPathD(this.connectionStart.x, this.connectionStart.y, world.x, world.y)); + this.tempConnection.setAttribute( + 'd', + this.getPathD( + this.connectionStart.x, + this.connectionStart.y, + world.x, + world.y, + this.connectionStart.handle + ) + ); } onPortMouseUp(e: any, nodeId: any, handle: any) { e.stopPropagation(); if (this.connectionStart && this.connectionStart.nodeId !== nodeId) { - const nextConnection: WorkflowConnection = { - source: this.connectionStart.nodeId, - target: nodeId, - sourceHandle: this.connectionStart.handle, - targetHandle: handle - }; - const duplicateExists = this.connections.some( - (conn: any) => - conn.source === nextConnection.source && - conn.target === nextConnection.target && - conn.sourceHandle === nextConnection.sourceHandle && - conn.targetHandle === nextConnection.targetHandle - ); - // If we're reconnecting an existing connection, create new connection with updated target - if (this.reconnectingConnection !== null) { - // Connection was already removed from array, just create new one - if (!duplicateExists) { - this.connections.push(nextConnection); + if (this.connectionStart.handle === SUBAGENT_HANDLE && handle !== SUBAGENT_TARGET_HANDLE) { + this.setCanvasValidationMessage('Subagent links must connect to the top green subagent connector.'); + if (this.reconnectingConnection !== null) { + this.reconnectingConnection = null; + this.renderConnections(); } - this.reconnectingConnection = null; - } else { - // Creating a new connection - if (!duplicateExists) { - this.connections.push(nextConnection); + this.clearPendingConnectionDragState(); + this.updateRunButton(); + return; + } + + if (this.connectionStart.handle !== SUBAGENT_HANDLE && handle === SUBAGENT_TARGET_HANDLE) { + this.setCanvasValidationMessage('Regular workflow links must connect to the side input connector.'); + if (this.reconnectingConnection !== null) { + this.reconnectingConnection = null; + this.renderConnections(); } + this.clearPendingConnectionDragState(); + this.updateRunButton(); + return; } - this.renderConnections(); - if(this.tempConnection) this.tempConnection.remove(); - this.connectionStart = null; - this.tempConnection = null; - this.updateRunButton(); + + const targetHandle = handle === SUBAGENT_TARGET_HANDLE ? 'input' : handle; + this.applyConnectionToTarget(nodeId, targetHandle); } else if (this.reconnectingConnection !== null) { // Released without connecting to anything - connection already deleted, just clean up this.reconnectingConnection = null; @@ -1990,11 +2481,9 @@ export class WorkflowEditor { const sourceNode = this.nodes.find((n: any) => n.id === connection.source); if (!sourceNode) return; - - const startYOffset = this.getOutputPortCenterYOffset(sourceNode, connection.sourceHandle); - - const startX = sourceNode.x + this.getNodeWidth(sourceNode); - const startY = sourceNode.y + startYOffset; + const startPoint = this.getConnectionStartPoint(sourceNode, connection.sourceHandle); + const startX = startPoint.x; + const startY = startPoint.y; const world = this.screenToWorld(e.clientX, e.clientY); this.connectionStart = { @@ -2011,13 +2500,21 @@ export class WorkflowEditor { // Create temp connection for dragging this.tempConnection = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - this.tempConnection.setAttribute('class', 'connection-line reconnecting'); - this.tempConnection.setAttribute('d', this.getPathD(startX, startY, world.x, world.y)); + this.tempConnection.setAttribute( + 'class', + this.getConnectionLineClass(connection.sourceHandle, { reconnecting: true }) + ); + this.tempConnection.setAttribute( + 'd', + this.getPathD(startX, startY, world.x, world.y, connection.sourceHandle) + ); this.connectionsLayer.appendChild(this.tempConnection); } - renderConnections() { + renderConnections(refreshSelectedForm = true) { if (!this.connectionsLayer) return; + this.enforceSubagentTargetInputLocks(); + this.updateSubagentTargetNodeStyles(); const connectionsLayer = this.connectionsLayer; // Clear only permanent lines const lines = Array.from(connectionsLayer.querySelectorAll('.connection-line')); @@ -2030,17 +2527,16 @@ export class WorkflowEditor { const targetNode = this.nodes.find((n: any) => n.id === conn.target); if (!sourceNode || !targetNode) return; - const startYOffset = this.getOutputPortCenterYOffset(sourceNode, conn.sourceHandle); - - // Calculate start/end points based on node position + standard port offsets - const startX = sourceNode.x + this.getNodeWidth(sourceNode); - const startY = sourceNode.y + startYOffset; - const endX = targetNode.x; - const endY = targetNode.y + 24; // Input port offset + const startPoint = this.getConnectionStartPoint(sourceNode, conn.sourceHandle); + const endPoint = this.getConnectionEndPoint(targetNode, conn.sourceHandle); + const startX = startPoint.x; + const startY = startPoint.y; + const endX = endPoint.x; + const endY = endPoint.y; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('class', 'connection-line editable'); - path.setAttribute('d', this.getPathD(startX, startY, endX, endY)); + path.setAttribute('class', this.getConnectionLineClass(conn.sourceHandle, { editable: true })); + path.setAttribute('d', this.getPathD(startX, startY, endX, endY, conn.sourceHandle)); path.dataset.connectionIndex = index; path.dataset.sourceNodeId = conn.source; path.dataset.sourceHandle = conn.sourceHandle; @@ -2048,10 +2544,37 @@ export class WorkflowEditor { path.addEventListener('mousedown', (e: any) => this.onConnectionLineMouseDown(e, conn, index)); connectionsLayer.appendChild(path); }); + if (refreshSelectedForm) { + this.refreshSelectedNodeForm(); + } this.scheduleSave(); } - getPathD(startX: number, startY: number, endX: number, endY: number): string { + updateSubagentTargetNodeStyles(connections: WorkflowConnection[] = this.connections): void { + const subagentTargets = this.getSubagentTargetIds(connections); + this.nodes.forEach((node) => { + const el = document.getElementById(node.id); + if (!el) return; + const isSubagentTarget = node.type === 'agent' && subagentTargets.has(node.id); + el.classList.toggle('subagent-target-node', isSubagentTarget); + const isSubagentCandidate = + node.type === 'agent' && + (isSubagentTarget || this.canNodeBecomeSubagentTarget(node.id, connections)); + el.classList.toggle('subagent-candidate-node', isSubagentCandidate); + }); + } + + getPathD( + startX: number, + startY: number, + endX: number, + endY: number, + sourceHandle?: string + ): string { + if (sourceHandle === SUBAGENT_HANDLE) { + const verticalControlOffset = Math.max(40, Math.abs(endY - startY) * 0.35); + return `M ${startX} ${startY} C ${startX} ${startY + verticalControlOffset}, ${endX} ${endY - verticalControlOffset}, ${endX} ${endY}`; + } const controlPointOffset = Math.abs(endX - startX) * 0.5; return `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`; } @@ -2227,6 +2750,7 @@ export class WorkflowEditor { this.chatMessages.innerHTML = ''; this.pendingAgentMessages.clear(); this.pendingAgentMessageCounts.clear(); + this.clearSubagentSpinnerState(); if (typeof _promptText === 'string' && _promptText.trim()) { this.appendChatMessage(_promptText, 'user'); } @@ -2281,6 +2805,260 @@ export class WorkflowEditor { return (node?.data?.agentName || '').trim() || 'Agent'; } + isSubagentCallLogType(type: string): boolean { + return ( + type === 'subagent_call_start' || + type === 'subagent_call_end' || + type === 'subagent_call_error' + ); + } + + parseSubagentRuntimeLogPayload(entry: any): SubagentRuntimeLogPayload | null { + if (!this.isSubagentCallLogType(entry?.type || '')) { + return null; + } + if (typeof entry?.content !== 'string' || !entry.content.trim()) { + return null; + } + + try { + const parsed = JSON.parse(entry.content) as Partial; + if ( + typeof parsed.callId !== 'string' || + typeof parsed.subagentNodeId !== 'string' || + typeof parsed.subagentName !== 'string' || + typeof parsed.depth !== 'number' + ) { + return null; + } + return { + callId: parsed.callId, + subagentNodeId: parsed.subagentNodeId, + subagentName: parsed.subagentName, + depth: parsed.depth, + parentCallId: typeof parsed.parentCallId === 'string' ? parsed.parentCallId : undefined, + parentNodeId: typeof parsed.parentNodeId === 'string' ? parsed.parentNodeId : undefined, + message: typeof parsed.message === 'string' ? parsed.message : undefined + }; + } catch { + return null; + } + } + + ensureSpinnerSubagentList(spinner: HTMLElement): HTMLElement { + const existing = spinner.querySelector('.chat-subagent-list'); + if (existing instanceof HTMLElement) { + return existing; + } + const created = document.createElement('div'); + created.className = 'chat-subagent-list'; + spinner.appendChild(created); + return created; + } + + ensureSpinnerSubagentSummary(spinner: HTMLElement): HTMLElement { + const existing = spinner.querySelector('.chat-subagent-summary'); + if (existing instanceof HTMLElement) { + return existing; + } + + const list = this.ensureSpinnerSubagentList(spinner); + const created = document.createElement('div'); + created.className = 'chat-subagent-summary'; + spinner.insertBefore(created, list); + return created; + } + + updateSubagentToggleState(item: HTMLElement): void { + const toggle = item.querySelector('.chat-subagent-toggle'); + if (!(toggle instanceof HTMLButtonElement)) return; + + const hasChildren = item.classList.contains('has-children'); + if (!hasChildren) { + toggle.hidden = true; + toggle.disabled = true; + toggle.textContent = ''; + toggle.removeAttribute('aria-label'); + return; + } + + const isCollapsed = item.classList.contains('collapsed'); + toggle.hidden = false; + toggle.disabled = false; + toggle.textContent = isCollapsed ? '▸' : '▾'; + toggle.setAttribute('aria-label', isCollapsed ? 'Expand nested subagents' : 'Collapse nested subagents'); + } + + markSubagentItemHasChildren(callId: string): void { + const parentItem = this.subagentCallElements.get(callId); + if (!parentItem) return; + parentItem.classList.add('has-children'); + parentItem.classList.remove('collapsed'); + this.updateSubagentToggleState(parentItem); + } + + updateSpinnerSubagentSummary(spinnerKey: string): void { + const spinner = this.pendingAgentMessages.get(spinnerKey); + if (!spinner) return; + + const callIds = this.spinnerSubagentCallIds.get(spinnerKey); + if (!callIds || callIds.size === 0) { + const existing = spinner.querySelector('.chat-subagent-summary'); + if (existing instanceof HTMLElement) { + existing.remove(); + } + return; + } + + let running = 0; + let completed = 0; + let failed = 0; + + callIds.forEach((callId) => { + const status = this.subagentCallStatuses.get(callId); + if (status === 'running') running += 1; + else if (status === 'completed') completed += 1; + else if (status === 'failed') failed += 1; + }); + + const summary = this.ensureSpinnerSubagentSummary(spinner); + const parts = [`${running} running`, `${completed} done`]; + if (failed > 0) { + parts.push(`${failed} failed`); + } + summary.textContent = `Subagents: ${parts.join(' · ')}`; + } + + ensureSubagentCallItem(spinnerKey: string, payload: SubagentRuntimeLogPayload): HTMLElement | null { + const existing = this.subagentCallElements.get(payload.callId); + if (existing) { + return existing; + } + + const spinner = this.pendingAgentMessages.get(spinnerKey); + if (!spinner) { + return null; + } + + const item = document.createElement('div'); + item.className = 'chat-subagent-item running'; + item.dataset.callId = payload.callId; + item.dataset.depth = String(payload.depth); + + const row = document.createElement('div'); + row.className = 'chat-subagent-row'; + const main = document.createElement('div'); + main.className = 'chat-subagent-main'; + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'chat-subagent-toggle'; + toggle.hidden = true; + toggle.disabled = true; + toggle.addEventListener('click', (event) => { + event.preventDefault(); + item.classList.toggle('collapsed'); + this.updateSubagentToggleState(item); + }); + const name = document.createElement('span'); + name.className = 'chat-subagent-name'; + name.textContent = payload.subagentName; + main.appendChild(toggle); + main.appendChild(name); + const status = document.createElement('span'); + status.className = 'chat-subagent-status'; + row.appendChild(main); + row.appendChild(status); + item.appendChild(row); + + const children = document.createElement('div'); + children.className = 'chat-subagent-children'; + item.appendChild(children); + + const parentContainer = payload.parentCallId + ? (() => { + this.markSubagentItemHasChildren(payload.parentCallId); + return this.subagentCallElements.get(payload.parentCallId)?.querySelector('.chat-subagent-children'); + })() + : null; + const hostContainer = parentContainer instanceof HTMLElement + ? parentContainer + : this.ensureSpinnerSubagentList(spinner); + hostContainer.appendChild(item); + this.updateSubagentToggleState(item); + + this.subagentCallElements.set(payload.callId, item); + this.subagentCallSpinnerKeys.set(payload.callId, spinnerKey); + const callIdsForSpinner = this.spinnerSubagentCallIds.get(spinnerKey) ?? new Set(); + callIdsForSpinner.add(payload.callId); + this.spinnerSubagentCallIds.set(spinnerKey, callIdsForSpinner); + return item; + } + + setSubagentCallItemStatus( + callId: string, + statusClass: SubagentCallStatus, + message?: string + ): void { + const item = this.subagentCallElements.get(callId); + if (!item) return; + this.subagentCallStatuses.set(callId, statusClass); + item.classList.remove('running', 'completed', 'failed'); + item.classList.add(statusClass); + const statusEl = item.querySelector('.chat-subagent-status'); + if (!(statusEl instanceof HTMLElement)) return; + statusEl.classList.remove('running-indicator', 'done-indicator'); + statusEl.replaceChildren(); + + if (statusClass === 'running') { + statusEl.classList.add('running-indicator'); + statusEl.setAttribute('aria-label', 'Running'); + const spinner = document.createElement('span'); + spinner.className = 'chat-spinner chat-spinner-inline'; + spinner.setAttribute('aria-hidden', 'true'); + spinner.innerHTML = ''; + statusEl.appendChild(spinner); + return; + } + if (statusClass === 'completed') { + statusEl.classList.add('done-indicator'); + statusEl.removeAttribute('aria-label'); + statusEl.textContent = '✓'; + return; + } + statusEl.removeAttribute('aria-label'); + statusEl.textContent = message && message.trim() ? message.trim() : 'Failed'; + } + + handleSubagentLogEntry(entry: any, options: { createSpinnerIfMissing?: boolean } = {}): void { + const payload = this.parseSubagentRuntimeLogPayload(entry); + if (!payload) return; + + const spinnerKey = + payload.parentNodeId || + (typeof entry?.nodeId === 'string' ? entry.nodeId : GENERIC_AGENT_SPINNER_KEY); + const createSpinnerIfMissing = options.createSpinnerIfMissing ?? true; + + if (!this.pendingAgentMessages.has(spinnerKey) && createSpinnerIfMissing) { + this.showAgentSpinner(this.getAgentNameForNode(spinnerKey), spinnerKey); + } + if (!this.pendingAgentMessages.has(spinnerKey)) { + return; + } + + if (entry.type === 'subagent_call_start') { + const item = this.ensureSubagentCallItem(spinnerKey, payload); + if (item) { + this.setSubagentCallItemStatus(payload.callId, 'running'); + } + this.updateSpinnerSubagentSummary(spinnerKey); + return; + } + + const mappedStatus = entry.type === 'subagent_call_error' ? 'failed' : 'completed'; + this.setSubagentCallItemStatus(payload.callId, mappedStatus, payload.message); + this.updateSpinnerSubagentSummary(spinnerKey); + } + onLogEntry(entry: any) { const type = entry.type || ''; if (type === 'step_start') { @@ -2289,6 +3067,8 @@ export class WorkflowEditor { this.hideAgentSpinner(GENERIC_AGENT_SPINNER_KEY); this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId), entry.nodeId); } + } else if (this.isSubagentCallLogType(type)) { + this.handleSubagentLogEntry(entry); } else if (type === 'llm_response') { this.hideAgentSpinner(GENERIC_AGENT_SPINNER_KEY); this.hideAgentSpinner(entry.nodeId); @@ -2309,12 +3089,14 @@ export class WorkflowEditor { this.chatMessages.innerHTML = ''; this.pendingAgentMessages.clear(); this.pendingAgentMessageCounts.clear(); + this.clearSubagentSpinnerState(); this.lastLlmResponseContent = null; const initialPromptFromLogs = this.getInitialPromptFromLogs(logs); if (initialPromptFromLogs) { this.appendChatMessage(initialPromptFromLogs, 'user'); } const activeAgentNodeCounts = new Map(); + const subagentEntries: any[] = []; logs.forEach((entry: any) => { const entryNodeId = typeof entry?.nodeId === 'string' ? entry.nodeId : null; const entryNode = entryNodeId ? this.getRunNodeById(entryNodeId) : undefined; @@ -2335,6 +3117,11 @@ export class WorkflowEditor { } } + if (this.isSubagentCallLogType(entry.type || '')) { + subagentEntries.push(entry); + return; + } + if (this.isApprovalInputLog(entry)) { const approvalText = this.formatLogContent(entry); if (approvalText) { @@ -2356,6 +3143,8 @@ export class WorkflowEditor { this.showAgentSpinner(this.getAgentNameForNode(nodeId), nodeId); } }); + + subagentEntries.forEach((entry) => this.handleSubagentLogEntry(entry, { createSpinnerIfMissing: false })); } async runWorkflow() { diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 9cc8c20..b77f421 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -149,6 +149,27 @@ body { display: flex; } +.canvas-validation-message { + position: absolute; + top: calc(var(--UI-Spacing-spacing-ms) + 52px); + right: var(--UI-Spacing-spacing-ms); + max-width: min(440px, 60vw); + padding: var(--UI-Spacing-spacing-xxs) var(--UI-Spacing-spacing-xs); + border: 1px solid var(--Colors-Alert-Error-Default); + border-radius: var(--UI-Radius-radius-xs); + background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 10%, white); + color: var(--Colors-Alert-Error-Default); + box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); + font-size: var(--Fonts-Body-Default-xs); + line-height: 1.35; + z-index: 5; + display: none; +} + +.canvas-validation-message.visible { + display: block; +} + .canvas-clear { position: absolute; bottom: var(--UI-Spacing-spacing-ms); @@ -239,6 +260,10 @@ path.connection-line { pointer-events: none; } +path.connection-line.connection-line-subagent { + stroke: var(--Colors-Alert-Success-Medium); +} + path.connection-line.editable { pointer-events: stroke; cursor: grab; @@ -250,6 +275,10 @@ path.connection-line.editable:hover { stroke-width: 4px; } +path.connection-line.editable.connection-line-subagent:hover { + stroke: color-mix(in srgb, var(--Colors-Alert-Success-Medium) 80%, black); +} + path.connection-line.editable:active { cursor: grabbing; } @@ -261,6 +290,10 @@ path.connection-line.reconnecting { pointer-events: none; } +path.connection-line.reconnecting.connection-line-subagent { + stroke: var(--Colors-Alert-Success-Medium); +} + path.connection-line.active { stroke: var(--Colors-Primary-Medium); stroke-width: 3px; @@ -291,6 +324,20 @@ path.connection-line.active { z-index: 10; } +.node.subagent-target-node { + border-color: var(--Colors-Alert-Success-Medium); +} + +.node.subagent-target-node.selected { + border-color: var(--Colors-Alert-Success-Medium); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--Colors-Alert-Success-Medium) 25%, white); +} + +.node.subagent-target-node .port-in, +.node.subagent-target-node .port-out { + display: none; +} + /* Expanded Mode (In-Node Editing) */ .node.expanded { width: var(--node-width-expanded); @@ -503,6 +550,28 @@ path.connection-line.active { line-height: 1; } .port-condition-fallback { border-color: var(--Colors-Alert-Error-Default); } +.port-subagent { + top: auto; + bottom: calc(-1 * var(--UI-Spacing-spacing-xs)); + left: calc(50% - var(--UI-Spacing-spacing-xs)); + right: auto; + border-color: var(--Colors-Alert-Success-Medium); +} + +.port-subagent-target { + top: calc(-1 * (var(--UI-Spacing-spacing-xs) + (var(--UI-Spacing-spacing-min) / 2))); + left: calc(50% - var(--UI-Spacing-spacing-xs)); + right: auto; + border-color: var(--Colors-Alert-Success-Medium); + opacity: 0; + pointer-events: none; +} + +.node.subagent-candidate-node .port-subagent-target, +.node.subagent-target-node .port-subagent-target { + opacity: 1; + pointer-events: auto; +} /* Form Styles inside Node */ .node-form label { @@ -652,6 +721,26 @@ path.connection-line.active { -webkit-text-fill-color: inherit; } +.prompt-highlight-wrapper.is-subagent-locked { + background: color-mix(in srgb, var(--Colors-Backgrounds-Main-Top) 88%, transparent); +} + +.prompt-highlight-wrapper.is-subagent-locked .prompt-highlight-backdrop { + display: none; +} + +.prompt-highlight-wrapper.is-subagent-locked .prompt-highlight-input { + color: var(--Colors-Input-Text-Placeholder); + -webkit-text-fill-color: var(--Colors-Input-Text-Placeholder); + cursor: not-allowed; +} + +.subagent-input-lock-note { + margin-top: var(--UI-Spacing-spacing-xxs); + color: var(--Colors-Text-Body-Medium); + font-size: var(--Fonts-Body-Default-sm); +} + .ds-select { width: 100%; height: var(--UI-Input-sm); @@ -762,13 +851,15 @@ path.connection-line.active { } .chat-message.agent.spinner { - opacity: 0.85; + opacity: 0.92; + border-radius: var(--UI-Radius-radius-s); + padding: var(--UI-Spacing-spacing-s); } .chat-spinner-row { display: flex; align-items: center; - gap: var(--UI-Spacing-spacing-xxs); + gap: var(--UI-Spacing-spacing-xs); } .chat-spinner-text { @@ -803,6 +894,141 @@ path.connection-line.active { 40% { transform: scale(1); opacity: 1; } } +.chat-subagent-list { + margin-top: 0; + padding: var(--UI-Spacing-spacing-m) 0 0; + border: 0; + border-radius: 0; + background: transparent; + display: flex; + flex-direction: column; + gap: var(--UI-Spacing-spacing-xs); +} + +.chat-subagent-summary { + margin-top: var(--UI-Spacing-spacing-s); + color: var(--Colors-Text-Body-Medium); + font-size: var(--Fonts-Body-Default-xxs); +} + +.chat-subagent-item { + border: 1px solid var(--Colors-Stroke-Default); + border-radius: var(--UI-Radius-radius-xs); + padding: var(--UI-Spacing-spacing-xs) var(--UI-Spacing-spacing-s); + background: color-mix(in srgb, var(--Colors-Backgrounds-Main-Default) 82%, transparent); +} + +.chat-subagent-item.running { + border-left: 2px solid var(--Colors-Alert-Success-Medium); +} + +.chat-subagent-item.completed { + border-left: 2px solid var(--Colors-Alert-Success-Medium-Dark); +} + +.chat-subagent-item.failed { + border-left: 2px solid var(--Colors-Alert-Error-Default); +} + +.chat-subagent-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--UI-Spacing-spacing-s); + min-height: calc(var(--UI-Spacing-spacing-m) + var(--UI-Spacing-spacing-xxs)); +} + +.chat-subagent-main { + display: inline-flex; + align-items: center; + gap: var(--UI-Spacing-spacing-xxs); + min-width: 0; +} + +.chat-subagent-toggle { + border: 0; + background: transparent; + color: var(--Colors-Text-Body-Medium); + width: calc(var(--UI-Spacing-spacing-ms) - var(--UI-Spacing-spacing-min)); + min-width: calc(var(--UI-Spacing-spacing-ms) - var(--UI-Spacing-spacing-min)); + height: calc(var(--UI-Spacing-spacing-ms) - var(--UI-Spacing-spacing-min)); + padding: 0; + cursor: pointer; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-subagent-toggle:disabled { + cursor: default; + opacity: 0; +} + +.chat-subagent-name { + color: var(--Colors-Text-Body-Strong); + font-size: var(--Fonts-Body-Default-xxs); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-subagent-status { + color: var(--Colors-Text-Body-Medium); + font-size: var(--Fonts-Body-Default-xxs); + padding: calc(var(--UI-Spacing-spacing-min) / 2) var(--UI-Spacing-spacing-xs); + border-radius: var(--UI-Radius-radius-mxl); + background: color-mix(in srgb, var(--Colors-Backgrounds-Main-Top) 86%, transparent); +} + +.chat-subagent-status.running-indicator, +.chat-subagent-status.done-indicator { + min-width: calc(var(--UI-Spacing-spacing-m) + var(--UI-Spacing-spacing-xxs)); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-subagent-status.running-indicator .chat-spinner.chat-spinner-inline { + gap: var(--UI-Spacing-spacing-min); +} + +.chat-subagent-status.running-indicator .chat-spinner.chat-spinner-inline span { + width: calc((var(--UI-Spacing-spacing-xs) + var(--UI-Spacing-spacing-xxs)) / 2); + height: calc((var(--UI-Spacing-spacing-xs) + var(--UI-Spacing-spacing-xxs)) / 2); +} + +.chat-subagent-item.completed > .chat-subagent-row > .chat-subagent-status { + color: var(--Colors-Alert-Success-Medium-Dark); + background: color-mix(in srgb, var(--Colors-Alert-Success-Medium) 14%, transparent); +} + +.chat-subagent-item.completed > .chat-subagent-row > .chat-subagent-status.done-indicator { + font-size: var(--Fonts-Body-Default-sm); + font-weight: 700; + line-height: 1; +} + +.chat-subagent-item.failed > .chat-subagent-row > .chat-subagent-status { + color: var(--Colors-Alert-Error-Default); + background: color-mix(in srgb, var(--Colors-Alert-Error-Default) 14%, transparent); +} + +.chat-subagent-children { + margin-top: var(--UI-Spacing-spacing-xs); + margin-left: var(--UI-Spacing-spacing-s); + padding-left: var(--UI-Spacing-spacing-xs); + border-left: 1px dashed color-mix(in srgb, var(--Colors-Stroke-Default) 70%, transparent); + display: flex; + flex-direction: column; + gap: var(--UI-Spacing-spacing-xs); +} + +.chat-subagent-item.collapsed > .chat-subagent-children { + display: none; +} + .chat-message > div { margin: 0; } diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index 63c6c51..77f3c71 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -16,19 +16,53 @@ import type { export type AgentToolsConfig = { /** Enable web search capability for the agent */ web_search?: boolean; + /** Enable subagent delegation capability for the agent */ + subagents?: boolean; // Future tools can be added here, e.g.: calculator?: boolean; email?: boolean; }; +export interface AgentSubagentInvocation { + nodeId: string; + agentName: string; + systemPrompt: string; + model: string; + reasoningEffort?: string; + tools?: AgentToolsConfig; + subagents?: AgentSubagentInvocation[]; +} + export interface AgentInvocation { systemPrompt: string; userContent: string; model: string; reasoningEffort?: string; tools?: AgentToolsConfig; + subagents?: AgentSubagentInvocation[]; +} + +export type AgentRuntimeEventType = + | 'subagent_call_start' + | 'subagent_call_end' + | 'subagent_call_error'; + +export interface AgentRuntimeEvent { + type: AgentRuntimeEventType; + parentNodeId: string; + subagentNodeId: string; + subagentName: string; + callId: string; + parentCallId?: string; + depth: number; + message?: string; +} + +export interface AgentRespondOptions { + parentNodeId?: string; + onRuntimeEvent?: (event: AgentRuntimeEvent) => void; } export interface WorkflowLLM { - respond: (input: AgentInvocation) => Promise; + respond: (input: AgentInvocation, options?: AgentRespondOptions) => Promise; } export interface WorkflowEngineInitOptions { @@ -52,6 +86,7 @@ export interface WorkflowEngineInitOptions { const DEFAULT_REASONING = 'low'; const IF_CONDITION_HANDLE_PREFIX = 'condition-'; +const SUBAGENT_HANDLE = 'subagent'; const APPROVAL_CONTEXTS_STATE_KEY = '__approval_contexts__'; const PENDING_APPROVAL_QUEUE_STATE_KEY = '__pending_approval_queue__'; const DEFERRED_NODE_QUEUE_STATE_KEY = '__deferred_node_queue__'; @@ -252,6 +287,14 @@ export class WorkflowEngine { } } + private logAgentRuntimeEvent(fallbackNodeId: string, event: AgentRuntimeEvent): void { + const resolvedNodeId = + typeof event.parentNodeId === 'string' && event.parentNodeId.trim() + ? event.parentNodeId + : fallbackNodeId; + this.log(resolvedNodeId, event.type, JSON.stringify(event)); + } + private async processNode( node: WorkflowNode, previousOutput: unknown = this.state.previous_output, @@ -311,7 +354,7 @@ export class WorkflowEngine { } this.state[node.id] = output; - const nextConnections = this.graph.connections.filter((c) => c.source === node.id); + const nextConnections = this.getOutgoingExecutionConnections(node); await this.processConnections(node.id, nextConnections, output, writeSharedPreviousOutput); return output; } catch (error) { @@ -506,19 +549,25 @@ export class WorkflowEngine { userContent = lastOutputStr; } + this.validateSubagentGraphConstraints(); + const subagents = this.buildSubagentInvocations(node.id, new Set([node.id])); const invocation: AgentInvocation = { systemPrompt: (node.data?.systemPrompt as string) || 'You are a helpful assistant.', userContent, model: (node.data?.model as string) || 'gpt-5', reasoningEffort: (node.data?.reasoningEffort as string) || DEFAULT_REASONING, - tools: node.data?.tools as AgentToolsConfig + tools: node.data?.tools as AgentToolsConfig, + subagents: subagents.length > 0 ? subagents : undefined }; this.log(node.id, 'start_prompt', invocation.userContent || ''); try { - const responseText = await this.llm.respond(invocation); + const responseText = await this.llm.respond(invocation, { + parentNodeId: node.id, + onRuntimeEvent: (event) => this.logAgentRuntimeEvent(node.id, event) + }); this.log(node.id, 'llm_response', responseText); return responseText; } catch (error) { @@ -528,6 +577,151 @@ export class WorkflowEngine { } } + private isSubagentConnection(connection: WorkflowConnection): boolean { + return connection.sourceHandle === SUBAGENT_HANDLE; + } + + private getOutgoingExecutionConnections(node: WorkflowNode): WorkflowConnection[] { + const outgoing = this.graph.connections.filter((c) => c.source === node.id); + return outgoing.filter((connection) => !this.isSubagentConnection(connection)); + } + + private getSubagentConnections(): WorkflowConnection[] { + return this.graph.connections.filter((connection) => this.isSubagentConnection(connection)); + } + + private validateSubagentGraphConstraints(): void { + const subagentConnections = this.getSubagentConnections(); + if (subagentConnections.length === 0) { + return; + } + + const nodesById = new Map(this.graph.nodes.map((node) => [node.id, node])); + const incomingSubagentCounts = new Map(); + const subagentAdjacency = new Map(); + + for (const connection of subagentConnections) { + const sourceNode = nodesById.get(connection.source); + const targetNode = nodesById.get(connection.target); + + if (!sourceNode || sourceNode.type !== 'agent') { + throw new Error(`Subagent source "${connection.source}" must be an agent node.`); + } + if (!targetNode || targetNode.type !== 'agent') { + throw new Error(`Subagent target "${connection.target}" must be an agent node.`); + } + + const sourceTools = sourceNode.data?.tools as AgentToolsConfig | undefined; + if (!sourceTools?.subagents) { + throw new Error(`Agent "${sourceNode.id}" uses subagent links but Subagents tool is disabled.`); + } + + if (connection.targetHandle && connection.targetHandle !== 'input') { + throw new Error(`Subagent link "${sourceNode.id}" -> "${targetNode.id}" must connect to input handle.`); + } + + if (sourceNode.id === targetNode.id) { + throw new Error(`Agent "${sourceNode.id}" cannot be a subagent of itself.`); + } + + incomingSubagentCounts.set( + targetNode.id, + (incomingSubagentCounts.get(targetNode.id) ?? 0) + 1 + ); + if ((incomingSubagentCounts.get(targetNode.id) ?? 0) > 1) { + throw new Error(`Agent "${targetNode.id}" cannot belong to more than one parent subagent.`); + } + + const adjacent = subagentAdjacency.get(sourceNode.id) ?? []; + adjacent.push(targetNode.id); + subagentAdjacency.set(sourceNode.id, adjacent); + } + + const subagentTargetIds = new Set(incomingSubagentCounts.keys()); + for (const targetId of subagentTargetIds) { + const hasExecutionConnections = this.graph.connections.some((connection) => { + if (this.isSubagentConnection(connection)) { + return false; + } + return connection.source === targetId || connection.target === targetId; + }); + + if (hasExecutionConnections) { + throw new Error( + `Agent "${targetId}" is configured as subagent and cannot participate in workflow execution edges.` + ); + } + } + + const visitState = new Map(); + const dfs = (nodeId: string, path: string[]): void => { + const state = visitState.get(nodeId); + if (state === 'visiting') { + const startIndex = path.indexOf(nodeId); + const cyclePath = [...path.slice(startIndex), nodeId].join(' -> '); + throw new Error(`Subagent cycle detected: ${cyclePath}`); + } + if (state === 'visited') { + return; + } + + visitState.set(nodeId, 'visiting'); + const neighbors = subagentAdjacency.get(nodeId) ?? []; + for (const neighbor of neighbors) { + dfs(neighbor, [...path, neighbor]); + } + visitState.set(nodeId, 'visited'); + }; + + for (const nodeId of subagentAdjacency.keys()) { + dfs(nodeId, [nodeId]); + } + } + + private buildSubagentInvocations( + parentNodeId: string, + ancestry: Set + ): AgentSubagentInvocation[] { + const subagentConnections = this.graph.connections.filter( + (connection) => + connection.source === parentNodeId && + this.isSubagentConnection(connection) + ); + + if (subagentConnections.length === 0) { + return []; + } + + const results: AgentSubagentInvocation[] = []; + + for (const connection of subagentConnections) { + const targetNode = this.graph.nodes.find((candidate) => candidate.id === connection.target); + if (!targetNode || targetNode.type !== 'agent') { + throw new Error(`Subagent target "${connection.target}" must be an agent node.`); + } + + if (ancestry.has(targetNode.id)) { + throw new Error(`Subagent cycle detected at "${targetNode.id}".`); + } + + const nextAncestry = new Set(ancestry); + nextAncestry.add(targetNode.id); + const nestedSubagents = this.buildSubagentInvocations(targetNode.id, nextAncestry); + + results.push({ + nodeId: targetNode.id, + agentName: (targetNode.data?.agentName as string) || 'Agent', + systemPrompt: (targetNode.data?.systemPrompt as string) || 'You are a helpful assistant.', + model: (targetNode.data?.model as string) || 'gpt-5', + reasoningEffort: (targetNode.data?.reasoningEffort as string) || DEFAULT_REASONING, + tools: targetNode.data?.tools as AgentToolsConfig, + subagents: nestedSubagents.length > 0 ? nestedSubagents : undefined + }); + } + + return results; + } + private findLastNonApprovalOutput(): string | null { const entries = Object.entries(this.state); for (let i = entries.length - 1; i >= 0; i -= 1) {