From 0852ce26fe6cc6270505b0f599affe6449ab9b45 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 21 Dec 2025 00:09:12 -0800 Subject: [PATCH] fix: improve subagent UI display and interruption handling (#330) Co-authored-by: Letta --- src/agent/approval-execution.ts | 7 ++-- src/agent/subagents/manager.ts | 41 +++++++++++++++++++++ src/cli/App.tsx | 2 +- src/cli/components/ApprovalDialogRich.tsx | 40 ++++++++++++++++++++ src/cli/components/SubagentGroupDisplay.tsx | 14 +++++-- src/cli/components/SubagentGroupStatic.tsx | 10 ++++- src/cli/components/ToolCallMessageRich.tsx | 13 +++++-- src/cli/helpers/accumulator.ts | 3 +- src/cli/helpers/subagentDisplay.ts | 17 ++++++--- src/cli/helpers/subagentState.ts | 3 +- src/constants.ts | 5 +++ src/tools/impl/Bash.ts | 3 +- src/tools/impl/Task.ts | 6 ++- src/tools/manager.ts | 26 +++++++++---- 14 files changed, 161 insertions(+), 29 deletions(-) diff --git a/src/agent/approval-execution.ts b/src/agent/approval-execution.ts index 8b44b0f..74a09b6 100644 --- a/src/agent/approval-execution.ts +++ b/src/agent/approval-execution.ts @@ -6,6 +6,7 @@ import type { } from "@letta-ai/letta-client/resources/agents/messages"; import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools"; import type { ApprovalRequest } from "../cli/helpers/stream"; +import { INTERRUPTED_BY_USER } from "../constants"; import { executeTool, type ToolExecutionResult } from "../tools/manager"; export type ApprovalDecision = @@ -37,14 +38,14 @@ async function executeSingleDecision( id: "dummy", date: new Date().toISOString(), tool_call_id: decision.approval.toolCallId, - tool_return: "User interrupted tool execution", + tool_return: INTERRUPTED_BY_USER, status: "error", }); } return { type: "tool", tool_call_id: decision.approval.toolCallId, - tool_return: "User interrupted tool execution", + tool_return: INTERRUPTED_BY_USER, status: "error", }; } @@ -105,7 +106,7 @@ async function executeSingleDecision( e instanceof Error && (e.name === "AbortError" || e.message === "The operation was aborted"); const errorMessage = isAbortError - ? "User interrupted tool execution" + ? INTERRUPTED_BY_USER : `Error executing tool: ${String(e)}`; if (onChunk) { diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index 7108617..8b2fe73 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -13,6 +13,7 @@ import { addToolCall, updateSubagent, } from "../../cli/helpers/subagentState.js"; +import { INTERRUPTED_BY_USER } from "../../constants"; import { cliPermissions } from "../../permissions/cli"; import { permissionMode } from "../../permissions/mode"; import { sessionPermissions } from "../../permissions/session"; @@ -35,6 +36,7 @@ export interface SubagentResult { report: string; success: boolean; error?: string; + totalTokens?: number; } /** @@ -362,7 +364,18 @@ async function executeSubagent( baseURL: string, subagentId: string, isRetry = false, + signal?: AbortSignal, ): Promise { + // Check if already aborted before starting + if (signal?.aborted) { + return { + agentId: "", + report: "", + success: false, + error: INTERRUPTED_BY_USER, + }; + } + // Update the state with the model being used (may differ on retry/fallback) updateSubagent(subagentId, { model }); @@ -375,6 +388,14 @@ async function executeSubagent( env: process.env, }); + // Set up abort handler to kill the child process + let wasAborted = false; + const abortHandler = () => { + wasAborted = true; + proc.kill("SIGTERM"); + }; + signal?.addEventListener("abort", abortHandler); + const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -409,6 +430,19 @@ async function executeSubagent( proc.on("error", () => resolve(null)); }); + // Clean up abort listener + signal?.removeEventListener("abort", abortHandler); + + // Check if process was aborted by user + if (wasAborted) { + return { + agentId: state.agentId || "", + report: "", + success: false, + error: INTERRUPTED_BY_USER, + }; + } + const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim(); // Handle non-zero exit code @@ -426,6 +460,7 @@ async function executeSubagent( baseURL, subagentId, true, // Mark as retry to prevent infinite loops + signal, ); } } @@ -445,6 +480,7 @@ async function executeSubagent( report: state.finalResult, success: !state.finalError, error: state.finalError || undefined, + totalTokens: state.resultStats?.totalTokens, }; } @@ -455,6 +491,7 @@ async function executeSubagent( report: "", success: false, error: state.finalError, + totalTokens: state.resultStats?.totalTokens, }; } @@ -497,12 +534,14 @@ function getBaseURL(): string { * @param prompt - The task prompt for the subagent * @param userModel - Optional model override from the parent agent * @param subagentId - ID for tracking in the state store (registered by Task tool) + * @param signal - Optional abort signal for interruption handling */ export async function spawnSubagent( type: string, prompt: string, userModel: string | undefined, subagentId: string, + signal?: AbortSignal, ): Promise { const allConfigs = await getAllSubagentConfigs(); const config = allConfigs[type]; @@ -527,6 +566,8 @@ export async function spawnSubagent( prompt, baseURL, subagentId, + false, + signal, ); return result; diff --git a/src/cli/App.tsx b/src/cli/App.tsx index a9ddd3a..9153a97 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1577,11 +1577,11 @@ export default function App({ const handleInterrupt = useCallback(async () => { // If we're executing client-side tools, abort them locally instead of hitting the backend + // Don't show "Stream interrupted" banner - the tool result will show "Interrupted by user" if (isExecutingTool && toolAbortControllerRef.current) { toolAbortControllerRef.current.abort(); setStreaming(false); setIsExecutingTool(false); - appendError("Stream interrupted by user"); refreshDerived(); return; } diff --git a/src/cli/components/ApprovalDialogRich.tsx b/src/cli/components/ApprovalDialogRich.tsx index 76ad728..b1f290b 100644 --- a/src/cli/components/ApprovalDialogRich.tsx +++ b/src/cli/components/ApprovalDialogRich.tsx @@ -252,6 +252,45 @@ const DynamicPreview: React.FC = ({ ); } + // Task tool (subagent) - show nicely formatted preview + if (t === "task") { + const subagentType = + typeof parsedArgs?.subagent_type === "string" + ? parsedArgs.subagent_type + : "unknown"; + const description = + typeof parsedArgs?.description === "string" + ? parsedArgs.description + : "(no description)"; + const prompt = + typeof parsedArgs?.prompt === "string" + ? parsedArgs.prompt + : "(no prompt)"; + const model = + typeof parsedArgs?.model === "string" ? parsedArgs.model : undefined; + + // Truncate long prompts for preview (show first ~200 chars) + const maxPromptLength = 200; + const promptPreview = + prompt.length > maxPromptLength + ? `${prompt.slice(0, maxPromptLength)}...` + : prompt; + + return ( + + + {subagentType} + · + {description} + + {model && Model: {model}} + + {promptPreview} + + + ); + } + // File edit previews: write/edit/multi_edit/replace/write_file/write_file_gemini if ( (t === "write" || @@ -714,5 +753,6 @@ function getHeaderLabel(toolName: string): string { if (t === "write_file" || t === "writefile") return "Write File"; if (t === "killbash") return "Kill Shell"; if (t === "bashoutput") return "Shell Output"; + if (t === "task") return "Task"; return toolName; } diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index cd8598c..5caa0eb 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -131,7 +131,7 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => { {" ⎿ Done"} ) : agent.status === "error" ? ( - {" ⎿ Error: "} + {" ⎿ "} {agent.error} ) : lastTool ? ( @@ -151,21 +151,27 @@ AgentRow.displayName = "AgentRow"; interface GroupHeaderProps { count: number; allCompleted: boolean; + hasErrors: boolean; expanded: boolean; } const GroupHeader = memo( - ({ count, allCompleted, expanded }: GroupHeaderProps) => { + ({ count, allCompleted, hasErrors, expanded }: GroupHeaderProps) => { const statusText = allCompleted ? `Ran ${count} subagent${count !== 1 ? "s" : ""}` : `Running ${count} subagent${count !== 1 ? "s" : ""}…`; const hint = expanded ? "(ctrl+o to collapse)" : "(ctrl+o to expand)"; + // Use error color for dot if any subagent errored + const dotColor = hasErrors + ? colors.subagent.error + : colors.subagent.completed; + return ( {allCompleted ? ( - + ) : ( )} @@ -200,12 +206,14 @@ export const SubagentGroupDisplay = memo(() => { const allCompleted = agents.every( (a) => a.status === "completed" || a.status === "error", ); + const hasErrors = agents.some((a) => a.status === "error"); return ( {agents.map((agent, index) => ( diff --git a/src/cli/components/SubagentGroupStatic.tsx b/src/cli/components/SubagentGroupStatic.tsx index e14327a..3e7b6d9 100644 --- a/src/cli/components/SubagentGroupStatic.tsx +++ b/src/cli/components/SubagentGroupStatic.tsx @@ -87,7 +87,7 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { {" ⎿ Done"} ) : ( - {" ⎿ Error: "} + {" ⎿ "} {agent.error} )} @@ -109,12 +109,18 @@ export const SubagentGroupStatic = memo( } const statusText = `Ran ${agents.length} subagent${agents.length !== 1 ? "s" : ""}`; + const hasErrors = agents.some((a) => a.status === "error"); + + // Use error color for dot if any subagent errored + const dotColor = hasErrors + ? colors.subagent.error + : colors.subagent.completed; return ( {/* Header */} - + {statusText} diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 8f3f085..daacdaa 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { memo } from "react"; +import { INTERRUPTED_BY_USER } from "../../constants"; import { clipToolReturn } from "../../tools/manager.js"; import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js"; import { @@ -46,8 +47,14 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { const argsText = line.argsText ?? "..."; // Task tool - handled by SubagentGroupDisplay, don't render here + // Exception: Cancelled/rejected Task tools should be rendered inline + // since they won't appear in SubagentGroupDisplay if (isTaskTool(rawName)) { - return null; + const isCancelledOrRejected = + line.phase === "finished" && line.resultOk === false; + if (!isCancelledOrRejected) { + return null; + } } // Apply tool name remapping @@ -103,14 +110,14 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { ); } - if (line.resultText === "Interrupted by user") { + if (line.resultText === INTERRUPTED_BY_USER) { return ( {prefix} - Interrupted by user + {INTERRUPTED_BY_USER} ); diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index be7d7a6..923f64b 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -5,6 +5,7 @@ // - Exposes `onChunk` to feed SDK events and `toLines` to render. import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages"; +import { INTERRUPTED_BY_USER } from "../../constants"; // One line per transcript row. Tool calls evolve in-place. // For tool call returns, merge into the tool call matching the toolCallId @@ -172,7 +173,7 @@ export function markIncompleteToolsAsCancelled(b: Buffers) { ...line, phase: "finished" as const, resultOk: false, - resultText: "Interrupted by user", + resultText: INTERRUPTED_BY_USER, }; b.byId.set(id, updatedLine); } diff --git a/src/cli/helpers/subagentDisplay.ts b/src/cli/helpers/subagentDisplay.ts index ef16afd..66fe22e 100644 --- a/src/cli/helpers/subagentDisplay.ts +++ b/src/cli/helpers/subagentDisplay.ts @@ -8,7 +8,7 @@ * Format tool count and token statistics for display * * @param toolCount - Number of tool calls - * @param totalTokens - Total tokens used + * @param totalTokens - Total tokens used (0 or undefined means no data available) * @param isRunning - If true, shows "—" for tokens (since usage is only available at end) */ export function formatStats( @@ -16,12 +16,19 @@ export function formatStats( totalTokens: number, isRunning = false, ): string { - const tokenStr = isRunning - ? "—" - : totalTokens >= 1000 + const toolStr = `${toolCount} tool use${toolCount !== 1 ? "s" : ""}`; + + // Only show token count if we have actual data (not running and totalTokens > 0) + const hasTokenData = !isRunning && totalTokens > 0; + if (!hasTokenData) { + return toolStr; + } + + const tokenStr = + totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1)}k` : String(totalTokens); - return `${toolCount} tool use${toolCount !== 1 ? "s" : ""} · ${tokenStr} tokens`; + return `${toolStr} · ${tokenStr} tokens`; } /** diff --git a/src/cli/helpers/subagentState.ts b/src/cli/helpers/subagentState.ts index bd3524b..51be0da 100644 --- a/src/cli/helpers/subagentState.ts +++ b/src/cli/helpers/subagentState.ts @@ -180,7 +180,7 @@ export function addToolCall( */ export function completeSubagent( id: string, - result: { success: boolean; error?: string }, + result: { success: boolean; error?: string; totalTokens?: number }, ): void { const agent = store.agents.get(id); if (!agent) return; @@ -191,6 +191,7 @@ export function completeSubagent( status: result.success ? "completed" : "error", error: result.error, durationMs: Date.now() - agent.startTime, + totalTokens: result.totalTokens ?? agent.totalTokens, } as SubagentState; store.agents.set(id, updatedAgent); notifyListeners(); diff --git a/src/constants.ts b/src/constants.ts index 0c516a0..ec4c6e5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,3 +11,8 @@ export const DEFAULT_MODEL_ID = "sonnet-4.5"; * Default agent name when creating a new agent */ export const DEFAULT_AGENT_NAME = "Nameless Agent"; + +/** + * Message displayed when user interrupts tool execution + */ +export const INTERRUPTED_BY_USER = "Interrupted by user"; diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts index 68f3e7f..6820a84 100644 --- a/src/tools/impl/Bash.ts +++ b/src/tools/impl/Bash.ts @@ -1,6 +1,7 @@ import type { ExecOptions } from "node:child_process"; import { exec, spawn } from "node:child_process"; import { promisify } from "node:util"; +import { INTERRUPTED_BY_USER } from "../../constants"; import { backgroundProcesses, getNextBashId } from "./process_manager.js"; import { getShellEnv } from "./shellEnv.js"; import { LIMITS, truncateByChars } from "./truncation.js"; @@ -156,7 +157,7 @@ export async function bash(args: BashArgs): Promise { let errorMessage = ""; if (isAbort) { - errorMessage = "User interrupted tool execution"; + errorMessage = INTERRUPTED_BY_USER; } else { if (err.killed && err.signal === "SIGTERM") errorMessage = `Command timed out after ${effectiveTimeout}ms\n`; diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index 7fc42f6..002bf55 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -20,6 +20,7 @@ interface TaskArgs { description: string; model?: string; toolCallId?: string; // Injected by executeTool for linking subagent to parent tool call + signal?: AbortSignal; // Injected by executeTool for interruption handling } /** @@ -33,7 +34,8 @@ export async function task(args: TaskArgs): Promise { "Task", ); - const { subagent_type, prompt, description, model, toolCallId } = args; + const { subagent_type, prompt, description, model, toolCallId, signal } = + args; // Get all available subagent configs (built-in + custom) const allConfigs = await getAllSubagentConfigs(); @@ -54,12 +56,14 @@ export async function task(args: TaskArgs): Promise { prompt, model, subagentId, + signal, ); // Mark subagent as completed in state store completeSubagent(subagentId, { success: result.success, error: result.error, + totalTokens: result.totalTokens, }); if (!result.success) { diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 4a838bc..fbec8da 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -6,6 +6,7 @@ import { } from "@letta-ai/letta-client"; import { getModelInfo } from "../agent/model"; import { getAllSubagentConfigs } from "../agent/subagents"; +import { INTERRUPTED_BY_USER } from "../constants"; import { telemetry } from "../telemetry"; import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions"; @@ -911,9 +912,14 @@ export async function executeTool( enhancedArgs = { ...enhancedArgs, signal: options.signal }; } - // Inject toolCallId for Task tool (for linking subagents to their parent tool call) - if (internalName === "Task" && options?.toolCallId) { - enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId }; + // Inject toolCallId and abort signal for Task tool + if (internalName === "Task") { + if (options?.toolCallId) { + enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId }; + } + if (options?.signal) { + enhancedArgs = { ...enhancedArgs, signal: options.signal }; + } } const result = await tool.fn(enhancedArgs); @@ -925,23 +931,27 @@ export async function executeTool( const stderrValue = recordResult?.stderr; const stdout = isStringArray(stdoutValue) ? stdoutValue : undefined; const stderr = isStringArray(stderrValue) ? stderrValue : undefined; + + // Check if tool returned a status (e.g., Bash returns status: "error" on abort) + const toolStatus = recordResult?.status === "error" ? "error" : "success"; + // Flatten the response to plain text const flattenedResponse = flattenToolResponse(result); - // Track tool usage (success path - we're in the try block) + // Track tool usage telemetry.trackToolUsage( internalName, - true, // Hardcoded to true since tool execution succeeded + toolStatus === "success", duration, flattenedResponse.length, - undefined, // no error_type on success + toolStatus === "error" ? "tool_error" : undefined, stderr ? stderr.join("\n") : undefined, ); // Return the full response (truncation happens in UI layer only) return { toolReturn: flattenedResponse, - status: "success", + status: toolStatus, ...(stdout && { stdout }), ...(stderr && { stderr }), }; @@ -959,7 +969,7 @@ export async function executeTool( ? error.name : "unknown"; const errorMessage = isAbort - ? "User interrupted tool execution" + ? INTERRUPTED_BY_USER : error instanceof Error ? error.message : String(error);