From c5add0fc7d04af89395092694f758e88e1e06b1c Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Mon, 11 May 2026 19:07:48 -0400 Subject: [PATCH] subagent processing updates and fixes --- .../frontend/src/components/ChatTurn.tsx | 124 ++++++++-- gadget-code/frontend/src/index.css | 1 + gadget-code/frontend/src/lib/api.ts | 13 ++ gadget-code/frontend/src/lib/socket.ts | 34 ++- .../frontend/src/pages/ChatSessionView.tsx | 217 ++++++++++++++++-- gadget-code/src/lib/drone-session.ts | 80 +++++++ gadget-code/src/models/chat-turn.ts | 1 + gadget-drone/src/services/agent.ts | 63 ++++- packages/api/src/interfaces/chat-turn.ts | 15 +- packages/api/src/messages/socket.ts | 39 ++++ 10 files changed, 534 insertions(+), 53 deletions(-) diff --git a/gadget-code/frontend/src/components/ChatTurn.tsx b/gadget-code/frontend/src/components/ChatTurn.tsx index f6e6cdf..c3f0367 100644 --- a/gadget-code/frontend/src/components/ChatTurn.tsx +++ b/gadget-code/frontend/src/components/ChatTurn.tsx @@ -1,31 +1,17 @@ -import { useState, memo, useCallback, useEffect, useRef } from "react"; +import { memo } from "react"; import { marked } from "marked"; -import type { ChatTurn as ChatTurnType, ChatTurnBlock } from "../lib/api"; +import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api"; interface ChatTurnProps { turn: ChatTurnType; - onUpdate: (turnId: string, updates: Partial) => void; } // Configure marked with breaks enabled -marked.setOptions({ +marked.use({ breaks: true, }); -const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) { - const [thinkingExpanded, setThinkingExpanded] = useState(false); - const [toolCallsExpanded, setToolCallsExpanded] = useState(true); - const [renderedBlocks, setRenderedBlocks] = useState>(new Map()); - const blocksEndRef = useRef(null); - - const handleThinkingToggle = useCallback(() => { - setThinkingExpanded((prev) => !prev); - }, []); - - const handleToolCallsToggle = useCallback(() => { - setToolCallsExpanded((prev) => !prev); - }, []); - +const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) { const formatDuration = (ms: number): string => { if (ms < 1000) return `${ms}ms`; const seconds = (ms / 1000).toFixed(1); @@ -145,15 +131,22 @@ const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) { ); } else if (block.mode === 'tool') { const toolCall = block.content; + const subagent = toolCall.subagent; return (
{toolCall.name} - {toolCall.response && ( + {toolCall.response && !subagent && ( + + )} + {subagent && subagent.stats && ( )}
+ {subagent && ( + + )}
); } @@ -170,4 +163,97 @@ const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) { ); }); +function SubagentDisplay({ subagent }: { subagent: NonNullable }) { + const [expanded, setExpanded] = useState(true); + const toggle = useCallback(() => setExpanded((p) => !p), []); + + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + const seconds = (ms / 1000).toFixed(1); + return `${seconds}s`; + }; + + const formatTokenCount = (count: number): string => { + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return count.toString(); + }; + + return ( +
+ + + {expanded && ( +
+ {/* Thinking */} + {subagent.thinking && ( +
+
Thinking
+
+
+ )} + + {/* Subagent Tool Calls */} + {subagent.toolCalls.length > 0 && ( +
+
+ Tool Calls ({subagent.toolCalls.length}) +
+
+ {subagent.toolCalls.map((tc, i) => ( +
+ + {tc.name} + {tc.response && } +
+ ))} +
+
+ )} + + {/* Response */} + {subagent.response && ( +
+
Response
+
+
+ )} + + {/* Stats */} + {subagent.stats && ( +
+ {formatDuration(subagent.stats.durationMs)} + {formatTokenCount(subagent.stats.inputTokens)} in + {formatTokenCount(subagent.stats.responseTokens)} out + {subagent.stats.thinkingTokenCount > 0 && ( + {formatTokenCount(subagent.stats.thinkingTokenCount)} thinking + )} +
+ )} +
+ )} +
+ ); +} + export default ChatTurn; diff --git a/gadget-code/frontend/src/index.css b/gadget-code/frontend/src/index.css index 8651676..15654e5 100644 --- a/gadget-code/frontend/src/index.css +++ b/gadget-code/frontend/src/index.css @@ -92,6 +92,7 @@ input, textarea { color: var(--color-text-primary); line-height: 1.7; font-size: 14px; + white-space: pre-wrap; word-break: break-word; /* Prevent the markdown root from stretching to fill the scroll container — display:inline-block shrinks it to content width, so the diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index 0065d13..dcf874c 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -399,6 +399,18 @@ export interface ChatTurnBlockTool { name: string; parameters?: string; response?: string; + subagent?: { + agentId: string; + thinking: string; + response: string; + toolCalls: Array<{ + callId: string; + name: string; + parameters?: string; + response?: string; + }>; + stats: ChatTurnStats; + }; }; } @@ -422,6 +434,7 @@ export interface ChatTurn { name: string; parameters?: string; response?: string; + subagent?: ChatTurnBlockTool['content']['subagent']; }>; subagents: any[]; stats: ChatTurnStats; diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index ea4b2fb..20d1079 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -90,7 +90,19 @@ export interface SocketEvents { tool: string; result: unknown; }) => void; - "agent:complete": (data: { agentId: string }) => void; + "agent:complete": (data: { + agentId: string; + response?: string; + subagent?: Record; + stats?: { + toolCallCount: number; + inputTokens: number; + thinkingTokenCount: number; + responseTokens: number; + durationMs: number; + durationLabel: string; + }; + }) => void; "log:entry": (data: { level: string; component: string; @@ -216,6 +228,26 @@ class SocketClient { }, ); + this.socket.on("agent:thinking", (data: unknown) => { + this.emit("agent:thinking", data as { agentId: string; thinking: string }); + }); + + this.socket.on("agent:response", (data: unknown) => { + this.emit("agent:response", data as { agentId: string; chunk: string }); + }); + + this.socket.on("agent:tool-call", (data: unknown) => { + this.emit("agent:tool-call", data as { agentId: string; tool: string; args: unknown }); + }); + + this.socket.on("agent:tool-result", (data: unknown) => { + this.emit("agent:tool-result", data as { agentId: string; tool: string; result: unknown }); + }); + + this.socket.on("agent:complete", (data: unknown) => { + this.emit("agent:complete", data as { agentId: string; response?: string; subagent?: Record; stats?: Record }); + }); + this._socket.on("connect", () => { this.reconnectAttempts = 0; this.emit("connect"); diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index eda5b7c..90b67c8 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -25,6 +25,21 @@ interface StreamingState { currentBlockIndex: number | null; } +interface SubagentStreamState { + agentId: string; + name: string; + parameters: string; + thinking: string; + response: string; + toolCalls: Array<{ + callId: string; + name: string; + parameters?: string; + response?: string; + }>; + stats?: ChatTurnStats; +} + export default function ChatSessionView() { const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>(); const navigate = useNavigate(); @@ -63,6 +78,7 @@ export default function ChatSessionView() { const updateRafRef = useRef(null); const currentTurnIdRef = useRef(null); const streamingStateRef = useRef>(new Map()); + const subagentStateRef = useRef>(new Map()); const sessionRef = useRef(null); const projectRef = useRef(null); @@ -202,6 +218,11 @@ export default function ChatSessionView() { socketClient.on('sessionUpdated', handleSessionUpdated); socketClient.on('log:entry', handleLogEntry); socketClient.on('status', handleStatus); + socketClient.on('agent:thinking', handleAgentThinking); + socketClient.on('agent:response', handleAgentResponse); + socketClient.on('agent:tool-call', handleAgentToolCall); + socketClient.on('agent:tool-result', handleAgentToolResult); + socketClient.on('agent:complete', handleAgentComplete); }; const cleanupSocketListeners = () => { @@ -213,6 +234,11 @@ export default function ChatSessionView() { socketClient.off('sessionUpdated', handleSessionUpdated); socketClient.off('log:entry', handleLogEntry); socketClient.off('status', handleStatus); + socketClient.off('agent:thinking', handleAgentThinking); + socketClient.off('agent:response', handleAgentResponse); + socketClient.off('agent:tool-call', handleAgentToolCall); + socketClient.off('agent:tool-result', handleAgentToolResult); + socketClient.off('agent:complete', handleAgentComplete); }; const scheduleUpdate = useCallback(() => { @@ -241,10 +267,18 @@ export default function ChatSessionView() { const updatedBlocks = [...(oldTurn.blocks || [])]; for (const updateBlock of turnUpdates.blocks) { - let blockIndex = state?.currentBlockIndex ?? null; + let blockIndex: number | null = state?.currentBlockIndex ?? null; if (updateBlock.mode === 'tool') { - blockIndex = null; + // For tool blocks with callId, find and replace existing + if (updateBlock.content?.callId) { + const existingIndex = updatedBlocks.findIndex( + b => b.mode === 'tool' && b.content?.callId === updateBlock.content?.callId + ); + blockIndex = existingIndex !== -1 ? existingIndex : null; + } else { + blockIndex = null; + } } else if ( blockIndex === null || updatedBlocks[blockIndex]?.mode !== updateBlock.mode @@ -300,10 +334,26 @@ export default function ChatSessionView() { }; if (existing?.blocks || updates.blocks) { - merged.blocks = [ - ...(existing?.blocks || []), - ...(updates.blocks || []), - ]; + const existingBlocks = [...(existing?.blocks || [])]; + const updateBlocks = updates.blocks || []; + + // Deduplicate tool blocks by callId: replace existing tool blocks + // that have the same callId as an incoming update block + merged.blocks = [...existingBlocks]; + for (const block of updateBlocks) { + if (block.mode === 'tool' && block.content?.callId) { + const existingIdx = merged.blocks.findIndex( + b => b.mode === 'tool' && b.content?.callId === block.content?.callId + ); + if (existingIdx !== -1) { + merged.blocks[existingIdx] = block; + } else { + merged.blocks.push(block); + } + } else { + merged.blocks.push(block); + } + } } if (existing?.toolCalls || updates.toolCalls) { @@ -435,6 +485,18 @@ export default function ChatSessionView() { state.currentBlockIndex = null; } + // Initialize subagent state for subagent tool calls + if (name === 'subagent') { + subagentStateRef.current.set(callId, { + agentId: callId, + name, + parameters: params, + thinking: '', + response: '', + toolCalls: [], + }); + } + // Add tool block mergePendingUpdate(turnId, { blocks: [{ @@ -458,6 +520,12 @@ export default function ChatSessionView() { } } + // Clean up subagent state for this turn + const subagentEntries = Array.from(subagentStateRef.current.entries()); + for (const [agentId] of subagentEntries) { + subagentStateRef.current.delete(agentId); + } + setTurns(prevTurns => prevTurns.map(turn => turn._id === turnId @@ -494,6 +562,131 @@ export default function ChatSessionView() { appContext?.setStatusMessage(content); }, [appContext]); + const defaultSubagentStats: ChatTurnStats = { + toolCallCount: 0, + inputTokens: 0, + thinkingTokenCount: 0, + responseTokens: 0, + durationMs: 0, + durationLabel: '0ms', + }; + + const buildSubagentBlock = useCallback((subState: SubagentStreamState): ChatTurnBlock => ({ + mode: 'tool' as const, + createdAt: new Date().toISOString(), + content: { + callId: subState.agentId, + name: subState.name, + parameters: subState.parameters, + response: subState.response, + subagent: { + agentId: subState.agentId, + thinking: subState.thinking, + response: subState.response, + toolCalls: subState.toolCalls, + stats: subState.stats || defaultSubagentStats, + }, + }, + }), []); + + const updateSubagentBlock = useCallback((turnId: string, agentId: string) => { + const subState = subagentStateRef.current.get(agentId); + if (!subState) return; + mergePendingUpdate(turnId, { + blocks: [buildSubagentBlock(subState)], + }); + scheduleUpdate(); + }, [mergePendingUpdate, scheduleUpdate, buildSubagentBlock]); + + const handleAgentThinking = useCallback((data: { agentId: string; thinking: string }) => { + const turnId = currentTurnIdRef.current; + if (!turnId) return; + + let subState = subagentStateRef.current.get(data.agentId); + if (!subState) { + subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] }; + subagentStateRef.current.set(data.agentId, subState); + } + subState.thinking += data.thinking; + updateSubagentBlock(turnId, data.agentId); + }, [updateSubagentBlock]); + + const handleAgentResponse = useCallback((data: { agentId: string; chunk: string }) => { + const turnId = currentTurnIdRef.current; + if (!turnId) return; + + let subState = subagentStateRef.current.get(data.agentId); + if (!subState) { + subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] }; + subagentStateRef.current.set(data.agentId, subState); + } + subState.response += data.chunk; + updateSubagentBlock(turnId, data.agentId); + }, [updateSubagentBlock]); + + const handleAgentToolCall = useCallback((data: { agentId: string; tool: string; args: unknown }) => { + const turnId = currentTurnIdRef.current; + if (!turnId) return; + + let subState = subagentStateRef.current.get(data.agentId); + if (!subState) { + subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] }; + subagentStateRef.current.set(data.agentId, subState); + } + const argsStr = typeof data.args === 'string' ? data.args : JSON.stringify(data.args); + subState.toolCalls.push({ callId: `tc_${Date.now()}_${subState.toolCalls.length}`, name: data.tool, parameters: argsStr }); + updateSubagentBlock(turnId, data.agentId); + }, [updateSubagentBlock]); + + const handleAgentToolResult = useCallback((data: { agentId: string; tool: string; result: unknown }) => { + const turnId = currentTurnIdRef.current; + if (!turnId) return; + + const subState = subagentStateRef.current.get(data.agentId); + if (!subState) return; + + const lastToolCall = subState.toolCalls[subState.toolCalls.length - 1]; + if (lastToolCall && lastToolCall.name === data.tool) { + lastToolCall.response = typeof data.result === 'string' ? data.result : JSON.stringify(data.result); + } + updateSubagentBlock(turnId, data.agentId); + }, [updateSubagentBlock]); + + const handleAgentComplete = useCallback((data: { agentId: string; response?: string; subagent?: Record; stats?: ChatTurnStats }) => { + const turnId = currentTurnIdRef.current; + if (!turnId) return; + + const subState = subagentStateRef.current.get(data.agentId); + if (!subState) return; + + // Apply final response if provided + if (data.response) { + subState.response = data.response; + } + + // Apply stats from agent:complete + if (data.stats) { + subState.stats = data.stats; + } + + // If subagent data was passed directly (e.g., from agent:complete with full payload), + // apply the thinking and toolCalls from it + if (data.subagent) { + const sa = data.subagent as Record; + if (typeof sa.thinking === 'string') subState.thinking = sa.thinking; + if (typeof sa.response === 'string') subState.response = sa.response; + if (Array.isArray(sa.toolCalls)) subState.toolCalls = sa.toolCalls as SubagentStreamState['toolCalls']; + if (sa.stats) subState.stats = { ...defaultSubagentStats, ...(sa.stats as Record) } as ChatTurnStats; + } + + updateSubagentBlock(turnId, data.agentId); + + // Clean up subagent state after final update + requestAnimationFrame(() => { + subagentStateRef.current.delete(data.agentId); + }); + }, [updateSubagentBlock]); + const showToast = useCallback((message: string) => { setToast(message); if (toastTimerRef.current) { @@ -675,8 +868,7 @@ export default function ChatSessionView() { mode: session?.mode || 'develop', status: 'processing', prompts: { user: promptInput.trim() }, - thinking: '', - response: '', + blocks: [], toolCalls: [], subagents: [], stats: { @@ -723,14 +915,6 @@ export default function ChatSessionView() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; - const handleTurnUpdate = useCallback((turnId: string, updates: Partial) => { - setTurns(prevTurns => - prevTurns.map(turn => - turn._id === turnId ? { ...turn, ...updates } : turn - ) - ); - }, []); - const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider; const promptPlaceholder = isEditingProvider ? 'Save or cancel provider changes to continue.' @@ -786,7 +970,6 @@ export default function ChatSessionView() { ))}
diff --git a/gadget-code/src/lib/drone-session.ts b/gadget-code/src/lib/drone-session.ts index 79e93b8..1db9a79 100644 --- a/gadget-code/src/lib/drone-session.ts +++ b/gadget-code/src/lib/drone-session.ts @@ -10,6 +10,8 @@ import { ChatTurnStatus, GadgetId, WorkspaceMode, + IChatToolCall, + IChatTurnBlock, } from "@gadget/api"; import { GadgetSocket, @@ -62,6 +64,12 @@ export class DroneSession extends SocketSession { this.socket.on("requestTermination", this.onRequestTermination.bind(this)); this.socket.on("log", this.onLog.bind(this)); + + this.socket.on("agent:thinking", this.onAgentThinking.bind(this)); + this.socket.on("agent:response", this.onAgentResponse.bind(this)); + this.socket.on("agent:tool-call", this.onAgentToolCall.bind(this)); + this.socket.on("agent:tool-result", this.onAgentToolResult.bind(this)); + this.socket.on("agent:complete", this.onAgentComplete.bind(this)); } async onLog( @@ -350,6 +358,78 @@ export class DroneSession extends SocketSession { codeSession.onWorkspaceModeChanged(mode); } + async onAgentThinking(data: { agentId: string; thinking: string }): Promise { + if (!this.chatSessionId) return; + try { + const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId); + codeSession.socket.emit("agent:thinking", data); + } catch (error) { + this.log.error("failed to route agent:thinking", { error }); + } + } + + async onAgentResponse(data: { agentId: string; chunk: string }): Promise { + if (!this.chatSessionId) return; + try { + const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId); + codeSession.socket.emit("agent:response", data); + } catch (error) { + this.log.error("failed to route agent:response", { error }); + } + } + + async onAgentToolCall(data: { agentId: string; tool: string; args: unknown }): Promise { + if (!this.chatSessionId) return; + try { + const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId); + codeSession.socket.emit("agent:tool-call", data); + } catch (error) { + this.log.error("failed to route agent:tool-call", { error }); + } + } + + async onAgentToolResult(data: { agentId: string; tool: string; result: unknown }): Promise { + if (!this.chatSessionId) return; + try { + const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId); + codeSession.socket.emit("agent:tool-result", data); + } catch (error) { + this.log.error("failed to route agent:tool-result", { error }); + } + } + + async onAgentComplete(data: { agentId: string; response?: string; subagent?: Record; stats?: Record }): Promise { + if (!this.chatSessionId) return; + try { + const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId); + codeSession.socket.emit("agent:complete", data); + } catch (error) { + this.log.error("failed to route agent:complete to frontend", { error }); + } + + // Update the persisted tool call with the final response and subagent data + if (this.currentTurnId && data.agentId) { + try { + const turn = await ChatTurn.findById(this.currentTurnId); + if (turn) { + const toolCall = turn.toolCalls.find((tc: IChatToolCall) => tc.callId === data.agentId); + if (toolCall) { + if (data.response) toolCall.response = data.response; + if (data.subagent) (toolCall as any).subagent = data.subagent; + } + const block = turn.blocks.find((b: IChatTurnBlock) => b.mode === 'tool' && (b.content as IChatToolCall).callId === data.agentId); + if (block && block.mode === 'tool') { + if (data.response) (block.content as IChatToolCall).response = data.response; + if (data.subagent) (block.content as any).subagent = data.subagent; + } + await turn.save(); + } + } catch (error) { + this.log.error("failed to update subagent tool call in DB", { error }); + } + } + } + /** * Called when the drone requests crash recovery for an incomplete work order. */ diff --git a/gadget-code/src/models/chat-turn.ts b/gadget-code/src/models/chat-turn.ts index dc83a29..453bb0e 100644 --- a/gadget-code/src/models/chat-turn.ts +++ b/gadget-code/src/models/chat-turn.ts @@ -45,6 +45,7 @@ export const ChatToolCallSchema = new Schema({ name: { type: String, required: true }, parameters: { type: String, required: false }, response: { type: String, required: false }, + subagent: { type: Schema.Types.Mixed, required: false } as any, }); export const ChatSubagentProcessSchema = new Schema({ diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 7ef60e1..4c555ea 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -78,6 +78,7 @@ class AgentService extends GadgetService { private toolbox = new AiToolbox(toolboxEnv); private currentWorkOrder: IAgentWorkOrder | null = null; private currentSocket: DroneSocket | null = null; + private currentToolCallId: string | null = null; get name(): string { return "AgentService"; @@ -250,18 +251,38 @@ class AgentService extends GadgetService { }); for (const toolCall of response.toolCalls) { + this.currentToolCallId = toolCall.callId; + + // Emit tool call start immediately so frontend shows it + if (toolCall.function.name === "subagent") { + socket.emit("toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, ""); + } + const result = await this.executeTool( toolCall.function.name, toolCall.function.arguments, ); - socket.emit( - "toolCall", - toolCall.callId, - toolCall.function.name, - toolCall.function.arguments, - result, - ); + if (toolCall.function.name === "subagent") { + let responseText = result; + let subagentData: Record | undefined; + try { + const parsed = JSON.parse(result); + if (parsed.success && parsed.data) { + subagentData = parsed.data; + responseText = parsed.data.response || result; + } + } catch {} + socket.emit("agent:complete", { + agentId: toolCall.callId, + response: responseText, + subagent: subagentData, + }); + } else { + socket.emit("toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, result); + } + + this.currentToolCallId = null; messages.push({ createdAt: turn.createdAt, @@ -412,6 +433,19 @@ class AgentService extends GadgetService { }; } + private makeAgentStreamHandler(socket: DroneSocket, agentId: string): IAiResponseStreamFn { + return async (chunk) => { + switch (chunk.type) { + case "thinking": + socket.emit("agent:thinking", { agentId, thinking: chunk.data }); + break; + case "response": + socket.emit("agent:response", { agentId, chunk: chunk.data }); + break; + } + }; + } + /** * Builds an IDroneModelConfig by looking up the model's stored settings * from the provider's cached models array. Falls back to safe defaults @@ -539,9 +573,11 @@ class AgentService extends GadgetService { { createdAt: new Date(), role: "user", content: prompt }, ]; + const agentId = this.currentToolCallId || `sa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const socket = this.currentSocket; const tools = this.getToolsForSubagent(agentType); - const streamHandler = this.currentSocket - ? this.makeStreamHandler(this.currentSocket) + const streamHandler = socket + ? this.makeAgentStreamHandler(socket, agentId) : undefined; let fullResponse = ""; @@ -604,11 +640,20 @@ class AgentService extends GadgetService { for (const toolCall of response.toolCalls) { const toolArgsRaw = toolCall.function.arguments; + + if (socket) { + socket.emit("agent:tool-call", { agentId, tool: toolCall.function.name, args: toolArgsRaw }); + } + const result = await this.executeTool( toolCall.function.name, toolArgsRaw, ); + if (socket) { + socket.emit("agent:tool-result", { agentId, tool: toolCall.function.name, result }); + } + subagentToolCalls.push({ callId: toolCall.callId, name: toolCall.function.name, diff --git a/packages/api/src/interfaces/chat-turn.ts b/packages/api/src/interfaces/chat-turn.ts index bf8689e..a2e8f2d 100644 --- a/packages/api/src/interfaces/chat-turn.ts +++ b/packages/api/src/interfaces/chat-turn.ts @@ -52,13 +52,6 @@ export interface IChatTurnStats { durationLabel: string; // total turn runtime as hh:mm:ss } -export interface IChatToolCall { - callId: string; // ID of the call so the agent can match response to call - name: string; // tool function name being called - parameters: string; // JSON.stringify of input parameters - response: string; // the tool's response -} - export interface IChatSubagentProcess { prompt: string; thinking?: string; @@ -67,6 +60,14 @@ export interface IChatSubagentProcess { stats: IChatTurnStats; } +export interface IChatToolCall { + callId: string; // ID of the call so the agent can match response to call + name: string; // tool function name being called + parameters: string; // JSON.stringify of input parameters + response: string; // the tool's response + subagent?: IChatSubagentProcess; // subagent execution details, only present for subagent tool calls +} + /** * A chat turn is a single prompt/response pair with tool call accounting. It * stores all data generated by one run of the Agentic Workflow Loop by a Gadget diff --git a/packages/api/src/messages/socket.ts b/packages/api/src/messages/socket.ts index 3765d22..e17d315 100644 --- a/packages/api/src/messages/socket.ts +++ b/packages/api/src/messages/socket.ts @@ -66,8 +66,42 @@ export interface ClientToServerEvents { requestCrashRecovery: RequestCrashRecoveryMessage; requestTermination: RequestTerminationMessage; workspaceModeChanged: WorkspaceModeChangedMessage; + "agent:thinking": AgentThinkingMessage; + "agent:response": AgentResponseMessage; + "agent:tool-call": AgentToolCallMessage; + "agent:tool-result": AgentToolResultMessage; + "agent:complete": AgentCompleteMessage; } +export type AgentThinkingMessage = (data: { + agentId: string; + thinking: string; +}) => void; + +export type AgentResponseMessage = (data: { + agentId: string; + chunk: string; +}) => void; + +export type AgentToolCallMessage = (data: { + agentId: string; + tool: string; + args: unknown; +}) => void; + +export type AgentToolResultMessage = (data: { + agentId: string; + tool: string; + result: unknown; +}) => void; + +export type AgentCompleteMessage = (data: { + agentId: string; + response?: string; + subagent?: Record; + stats?: Record; +}) => void; + export interface ServerToClientEvents { /* * gadget-code:web => gadget-drone @@ -93,6 +127,11 @@ export interface ServerToClientEvents { workOrderComplete: WorkOrderCompleteMessage; workspaceModeChanged: WorkspaceModeChangedMessage; sessionUpdated: SessionUpdatedMessage; + "agent:thinking": AgentThinkingMessage; + "agent:response": AgentResponseMessage; + "agent:tool-call": AgentToolCallMessage; + "agent:tool-result": AgentToolResultMessage; + "agent:complete": AgentCompleteMessage; } export interface SocketData {