fix: improve subagent UI display and interruption handling (#330)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<SubagentResult> {
|
||||
// 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<SubagentResult> {
|
||||
const allConfigs = await getAllSubagentConfigs();
|
||||
const config = allConfigs[type];
|
||||
@@ -527,6 +566,8 @@ export async function spawnSubagent(
|
||||
prompt,
|
||||
baseURL,
|
||||
subagentId,
|
||||
false,
|
||||
signal,
|
||||
);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -252,6 +252,45 @@ const DynamicPreview: React.FC<DynamicPreviewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Box flexDirection="row">
|
||||
<Text bold>{subagentType}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text>{description}</Text>
|
||||
</Box>
|
||||
{model && <Text dimColor>Model: {model}</Text>}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{promptPreview}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
) : agent.status === "error" ? (
|
||||
<Text color={colors.subagent.error}>
|
||||
{" ⎿ Error: "}
|
||||
{" ⎿ "}
|
||||
{agent.error}
|
||||
</Text>
|
||||
) : 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 (
|
||||
<Box flexDirection="row">
|
||||
{allCompleted ? (
|
||||
<Text color={colors.subagent.completed}>⏺</Text>
|
||||
<Text color={dotColor}>⏺</Text>
|
||||
) : (
|
||||
<BlinkDot color={colors.subagent.header} />
|
||||
)}
|
||||
@@ -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 (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<GroupHeader
|
||||
count={agents.length}
|
||||
allCompleted={allCompleted}
|
||||
hasErrors={hasErrors}
|
||||
expanded={expanded}
|
||||
/>
|
||||
{agents.map((agent, index) => (
|
||||
|
||||
@@ -87,7 +87,7 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
) : (
|
||||
<Text color={colors.subagent.error}>
|
||||
{" ⎿ Error: "}
|
||||
{" ⎿ "}
|
||||
{agent.error}
|
||||
</Text>
|
||||
)}
|
||||
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.completed}>⏺</Text>
|
||||
<Text color={dotColor}>⏺</Text>
|
||||
<Text color={colors.subagent.header}> {statusText}</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text color={colors.status.interrupt}>Interrupted by user</Text>
|
||||
<Text color={colors.status.interrupt}>{INTERRUPTED_BY_USER}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<BashResult> {
|
||||
|
||||
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`;
|
||||
|
||||
@@ -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<string> {
|
||||
"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<string> {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user