diff --git a/src/agent/subagents/index.ts b/src/agent/subagents/index.ts index 4e1d3bd..fa5922f 100644 --- a/src/agent/subagents/index.ts +++ b/src/agent/subagents/index.ts @@ -24,6 +24,7 @@ import historyAnalyzerAgentMd from "./builtin/history-analyzer.md"; import memoryAgentMd from "./builtin/memory.md"; import planAgentMd from "./builtin/plan.md"; import recallAgentMd from "./builtin/recall.md"; +import reflectionAgentMd from "./builtin/reflection.md"; const BUILTIN_SOURCES = [ exploreAgentMd, @@ -32,6 +33,7 @@ const BUILTIN_SOURCES = [ memoryAgentMd, planAgentMd, recallAgentMd, + reflectionAgentMd, ]; // Re-export for convenience diff --git a/src/cli/App.tsx b/src/cli/App.tsx index d6eb9fb..c2ee82f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -184,6 +184,7 @@ import { parseMemoryPreference, type ReflectionSettings, reflectionSettingsToLegacyMode, + shouldFireStepCountTrigger, } from "./helpers/memoryReminder"; import { type QueuedMessage, @@ -730,6 +731,19 @@ function formatReflectionSettings(settings: ReflectionSettings): string { return `Step count (every ${settings.stepCount} turns, ${behaviorLabel})`; } +const AUTO_REFLECTION_DESCRIPTION = "Reflect on recent conversations"; +const AUTO_REFLECTION_PROMPT = + "Review recent conversation history and update memory files with important information worth preserving."; + +function hasActiveReflectionSubagent(): boolean { + const snapshot = getSubagentSnapshot(); + return snapshot.agents.some( + (agent) => + agent.type.toLowerCase() === "reflection" && + (agent.status === "pending" || agent.status === "running"), + ); +} + function buildTextParts( ...parts: Array ): Array<{ type: "text"; text: string }> { @@ -7839,13 +7853,23 @@ ${SYSTEM_REMINDER_CLOSE} bashCommandCacheRef.current = []; } - // Build memory reminder if interval is set and we've reached the Nth turn - // When MemFS is enabled, this returns a reflection reminder instead - const memoryReminderContent = await buildMemoryReminder( - turnCountRef.current, - agentId, - ); const reflectionSettings = getReflectionSettings(); + const memfsEnabledForAgent = settingsManager.isMemfsEnabled(agentId); + const shouldFireStepTrigger = shouldFireStepCountTrigger( + turnCountRef.current, + reflectionSettings, + ); + let memoryReminderContent = ""; + if ( + shouldFireStepTrigger && + (reflectionSettings.behavior === "reminder" || !memfsEnabledForAgent) + ) { + // Step-count reminder mode (or non-memfs fallback) + memoryReminderContent = await buildMemoryReminder( + turnCountRef.current, + agentId, + ); + } // Increment turn count for next iteration turnCountRef.current += 1; @@ -7896,6 +7920,43 @@ ${SYSTEM_REMINDER_CLOSE} if (!text) return; reminderParts.push({ type: "text", text }); }; + const maybeLaunchReflectionSubagent = async ( + triggerSource: "step-count" | "compaction-event", + ) => { + if (!memfsEnabledForAgent) { + return false; + } + if (hasActiveReflectionSubagent()) { + debugLog( + "memory", + `Skipping auto reflection launch (${triggerSource}) because one is already active`, + ); + return false; + } + try { + const { spawnBackgroundSubagentTask } = await import( + "../tools/impl/Task" + ); + spawnBackgroundSubagentTask({ + subagentType: "reflection", + prompt: AUTO_REFLECTION_PROMPT, + description: AUTO_REFLECTION_DESCRIPTION, + }); + debugLog( + "memory", + `Auto-launched reflection subagent (${triggerSource})`, + ); + return true; + } catch (error) { + debugWarn( + "memory", + `Failed to auto-launch reflection subagent (${triggerSource}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + }; pushReminder(sessionContextReminder); // Inject available skills as system-reminder (LET-7353) @@ -7941,13 +8002,29 @@ ${SYSTEM_REMINDER_CLOSE} pushReminder(userPromptSubmitHookFeedback); pushReminder(memoryReminderContent); - // Consume compaction-triggered reflection/check reminder on next user turn. + // Step-count auto-launch mode: fire reflection in background on interval. + if ( + shouldFireStepTrigger && + reflectionSettings.trigger === "step-count" && + reflectionSettings.behavior === "auto-launch" + ) { + await maybeLaunchReflectionSubagent("step-count"); + } + + // Consume compaction-triggered reflection behavior on next user turn. if (contextTrackerRef.current.pendingReflectionTrigger) { contextTrackerRef.current.pendingReflectionTrigger = false; if (reflectionSettings.trigger === "compaction-event") { - const compactionReminderContent = - await buildCompactionMemoryReminder(agentId); - pushReminder(compactionReminderContent); + if ( + reflectionSettings.behavior === "auto-launch" && + memfsEnabledForAgent + ) { + await maybeLaunchReflectionSubagent("compaction-event"); + } else { + const compactionReminderContent = + await buildCompactionMemoryReminder(agentId); + pushReminder(compactionReminderContent); + } } } diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index 4ceb2b3..a0ca77d 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -77,6 +77,9 @@ const AgentRow = memo( const isRunning = agent.status === "pending" || agent.status === "running"; const shouldDim = isRunning && !agent.isBackground; + const showStats = !(agent.isBackground && isRunning); + const hideBackgroundStatusLine = + agent.isBackground && isRunning && !agent.agentURL; const stats = formatStats( agent.toolCalls.length, agent.totalTokens, @@ -126,22 +129,24 @@ const AgentRow = memo( {/* Simple status line */} - - - {" "} - {continueChar} - - {" "} - {agent.status === "error" ? ( - Error - ) : isComplete ? ( - Done - ) : agent.isBackground ? ( - Running in the background - ) : ( - Running... - )} - + {!hideBackgroundStatusLine && ( + + + {" "} + {continueChar} + + {" "} + {agent.status === "error" ? ( + Error + ) : isComplete ? ( + Done + ) : agent.isBackground ? ( + Running in the background + ) : ( + Running... + )} + + )} ); } @@ -177,10 +182,12 @@ const AgentRow = memo( )} )} - - {" · "} - {stats} - + {showStats && ( + + {" · "} + {stats} + + )} @@ -215,61 +222,63 @@ const AgentRow = memo( })} {/* Status line */} - - {agent.status === "completed" ? ( - <> - - {" "} - {continueChar} - - {" Done"} - - ) : agent.status === "error" ? ( - <> - - - - {" "} - {continueChar} + {!hideBackgroundStatusLine && ( + + {agent.status === "completed" ? ( + <> + + {" "} + {continueChar} + + {" Done"} + + ) : agent.status === "error" ? ( + <> + + + + {" "} + {continueChar} + + {" "} - {" "} + + + + {agent.error} + + + + ) : agent.isBackground ? ( + + + {" "} + {continueChar} - - - - {agent.error} + {" Running in the background"} + + ) : lastTool ? ( + <> + + {" "} + {continueChar} - - - ) : agent.isBackground ? ( - <> - - {" "} - {continueChar} - - {" Running in the background"} - - ) : lastTool ? ( - <> - - {" "} - {continueChar} - - - {" "} - {lastTool.name} - - - ) : ( - <> - - {" "} - {continueChar} - - {" Starting..."} - - )} - + + {" "} + {lastTool.name} + + + ) : ( + <> + + {" "} + {continueChar} + + {" Starting..."} + + )} + + )} ); }, diff --git a/src/cli/components/SubagentGroupStatic.tsx b/src/cli/components/SubagentGroupStatic.tsx index 4c836be..78f2baf 100644 --- a/src/cli/components/SubagentGroupStatic.tsx +++ b/src/cli/components/SubagentGroupStatic.tsx @@ -62,6 +62,9 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { const isRunning = agent.status === "running"; const shouldDim = isRunning && !agent.isBackground; + const showStats = !(agent.isBackground && isRunning); + const hideBackgroundStatusLine = + agent.isBackground && isRunning && !agent.agentURL; const stats = formatStats(agent.toolCount, agent.totalTokens, isRunning); const modelDisplay = getSubagentModelDisplay(agent.model); @@ -95,10 +98,12 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { )} )} - - {" · "} - {stats} - + {showStats && ( + + {" · "} + {stats} + + )} @@ -115,42 +120,44 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { )} {/* Status line */} - - {agent.status === "completed" && !agent.isBackground ? ( - <> - - {" "} - {continueChar} - - {" Done"} - - ) : agent.status === "error" ? ( - <> - - - - {" "} - {continueChar} + {!hideBackgroundStatusLine && ( + + {agent.status === "completed" && !agent.isBackground ? ( + <> + + {" "} + {continueChar} + + {" Done"} + + ) : agent.status === "error" ? ( + <> + + + + {" "} + {continueChar} + + {" "} - {" "} + + + + {agent.error} + + + + ) : ( + <> + + {" "} + {continueChar} - - - - {agent.error} - - - - ) : ( - <> - - {" "} - {continueChar} - - {" Running in the background"} - - )} - + {" Running in the background"} + + )} + + )} ); }); diff --git a/src/cli/helpers/memoryReminder.ts b/src/cli/helpers/memoryReminder.ts index 07493c8..ac41768 100644 --- a/src/cli/helpers/memoryReminder.ts +++ b/src/cli/helpers/memoryReminder.ts @@ -167,6 +167,17 @@ export function getMemoryReminderMode(): MemoryReminderMode { return reflectionSettingsToLegacyMode(getReflectionSettings()); } +export function shouldFireStepCountTrigger( + turnCount: number, + settings: ReflectionSettings = getReflectionSettings(), +): boolean { + if (settings.trigger !== "step-count") { + return false; + } + const stepCount = normalizeStepCount(settings.stepCount, DEFAULT_STEP_COUNT); + return turnCount > 0 && turnCount % stepCount === 0; +} + async function buildMemfsAwareMemoryReminder( agentId: string, trigger: "interval" | "compaction", @@ -221,12 +232,7 @@ export async function buildMemoryReminder( return ""; } - if ( - turnCount > 0 && - turnCount % - normalizeStepCount(reflectionSettings.stepCount, DEFAULT_STEP_COUNT) === - 0 - ) { + if (shouldFireStepCountTrigger(turnCount, reflectionSettings)) { debugLog( "memory", `Turn-based memory reminder fired (turn ${turnCount}, interval ${reflectionSettings.stepCount}, agent ${agentId})`, diff --git a/src/tests/agent/subagent-builtins.test.ts b/src/tests/agent/subagent-builtins.test.ts new file mode 100644 index 0000000..d17822e --- /dev/null +++ b/src/tests/agent/subagent-builtins.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { getAllSubagentConfigs } from "../../agent/subagents"; + +describe("built-in subagents", () => { + test("includes reflection subagent in available configs", async () => { + const configs = await getAllSubagentConfigs(); + expect(configs.reflection).toBeDefined(); + expect(configs.reflection?.name).toBe("reflection"); + }); +}); diff --git a/src/tests/cli/memoryReminder.test.ts b/src/tests/cli/memoryReminder.test.ts index 04fb26d..5003856 100644 --- a/src/tests/cli/memoryReminder.test.ts +++ b/src/tests/cli/memoryReminder.test.ts @@ -8,6 +8,7 @@ import { buildMemoryReminder, getReflectionSettings, reflectionSettingsToLegacyMode, + shouldFireStepCountTrigger, } from "../../cli/helpers/memoryReminder"; import { settingsManager } from "../../settings-manager"; @@ -144,4 +145,28 @@ describe("memoryReminder", () => { const reminder = await buildCompactionMemoryReminder("agent-1"); expect(reminder).toBe(MEMORY_REFLECTION_REMINDER); }); + + test("evaluates step-count trigger based on effective settings", () => { + expect( + shouldFireStepCountTrigger(10, { + trigger: "step-count", + behavior: "auto-launch", + stepCount: 5, + }), + ).toBe(true); + expect( + shouldFireStepCountTrigger(10, { + trigger: "step-count", + behavior: "reminder", + stepCount: 6, + }), + ).toBe(false); + expect( + shouldFireStepCountTrigger(10, { + trigger: "off", + behavior: "reminder", + stepCount: 5, + }), + ).toBe(false); + }); }); diff --git a/src/tests/cli/reflection-auto-launch-wiring.test.ts b/src/tests/cli/reflection-auto-launch-wiring.test.ts new file mode 100644 index 0000000..46e60af --- /dev/null +++ b/src/tests/cli/reflection-auto-launch-wiring.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("reflection auto-launch wiring", () => { + test("handles step-count and compaction-event auto-launch modes", () => { + const appPath = fileURLToPath( + new URL("../../cli/App.tsx", import.meta.url), + ); + const source = readFileSync(appPath, "utf-8"); + + expect(source).toContain("const maybeLaunchReflectionSubagent = async"); + expect(source).toContain( + 'await maybeLaunchReflectionSubagent("step-count")', + ); + expect(source).toContain( + 'await maybeLaunchReflectionSubagent("compaction-event")', + ); + expect(source).toContain("hasActiveReflectionSubagent()"); + expect(source).toContain("spawnBackgroundSubagentTask({"); + }); +}); diff --git a/src/tests/tools/task-background-helper.test.ts b/src/tests/tools/task-background-helper.test.ts new file mode 100644 index 0000000..7e7c877 --- /dev/null +++ b/src/tests/tools/task-background-helper.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import type { SubagentState } from "../../cli/helpers/subagentState"; +import { + clearAllSubagents, + registerSubagent, + updateSubagent, +} from "../../cli/helpers/subagentState"; +import { backgroundTasks } from "../../tools/impl/process_manager"; +import { + spawnBackgroundSubagentTask, + waitForBackgroundSubagentLink, +} from "../../tools/impl/Task"; + +describe("spawnBackgroundSubagentTask", () => { + let subagentCounter = 0; + const queueMessages: Array<{ + kind: "user" | "task_notification"; + text: string; + }> = []; + + const generateSubagentIdImpl = () => { + subagentCounter += 1; + return `subagent-test-${subagentCounter}`; + }; + + const registerSubagentImpl = mock( + ( + _id: string, + _type: string, + _description: string, + _toolCallId?: string, + _isBackground?: boolean, + ) => {}, + ); + const completeSubagentImpl = mock( + (_id: string, _result: { success: boolean; error?: string }) => {}, + ); + const buildSnapshot = (id: string): SubagentState => ({ + id, + type: "Reflection", + description: "Reflect on memory", + status: "running", + agentURL: null, + toolCalls: [ + { id: "tc-1", name: "Read", args: "{}" }, + { id: "tc-2", name: "Edit", args: "{}" }, + ], + totalTokens: 0, + durationMs: 0, + startTime: Date.now(), + }); + const getSubagentSnapshotImpl = () => ({ + agents: [buildSnapshot("subagent-test-1")], + expanded: false, + }); + const addToMessageQueueImpl = (msg: { + kind: "user" | "task_notification"; + text: string; + }) => { + queueMessages.push(msg); + }; + const formatTaskNotificationImpl = mock( + (_args: unknown) => "", + ); + const runSubagentStopHooksImpl = mock(async () => ({ + blocked: false, + errored: false, + feedback: [], + results: [], + })); + + beforeEach(() => { + subagentCounter = 0; + queueMessages.length = 0; + registerSubagentImpl.mockClear(); + completeSubagentImpl.mockClear(); + formatTaskNotificationImpl.mockClear(); + runSubagentStopHooksImpl.mockClear(); + backgroundTasks.clear(); + clearAllSubagents(); + }); + + afterEach(() => { + for (const task of backgroundTasks.values()) { + if (existsSync(task.outputFile)) { + unlinkSync(task.outputFile); + } + } + backgroundTasks.clear(); + clearAllSubagents(); + }); + + test("runs background subagent and preserves queue + hook behavior on success", async () => { + const spawnSubagentImpl = mock(async () => ({ + agentId: "agent-123", + conversationId: "default", + report: "reflection done", + success: true, + totalTokens: 55, + })); + + const launched = spawnBackgroundSubagentTask({ + subagentType: "reflection", + prompt: "Reflect", + description: "Reflect on memory", + deps: { + spawnSubagentImpl, + addToMessageQueueImpl, + formatTaskNotificationImpl, + runSubagentStopHooksImpl, + generateSubagentIdImpl, + registerSubagentImpl, + completeSubagentImpl, + getSubagentSnapshotImpl, + }, + }); + + expect(launched.taskId).toMatch(/^task_\d+$/); + expect(launched.subagentId).toBe("subagent-test-1"); + expect(backgroundTasks.get(launched.taskId)?.status).toBe("running"); + expect(registerSubagentImpl).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const task = backgroundTasks.get(launched.taskId); + expect(task?.status).toBe("completed"); + expect(task?.output[0]).toContain("reflection done"); + expect(completeSubagentImpl).toHaveBeenCalledTimes(1); + expect(queueMessages.length).toBe(1); + expect(runSubagentStopHooksImpl).toHaveBeenCalledWith( + "reflection", + "subagent-test-1", + true, + undefined, + "agent-123", + "default", + ); + + const outputContent = readFileSync(launched.outputFile, "utf-8"); + expect(outputContent).toContain("[Task started: Reflect on memory]"); + expect(outputContent).toContain("[Task completed]"); + }); + + test("marks background task failed and emits notification on error", async () => { + const spawnSubagentImpl = mock(async () => { + throw new Error("subagent exploded"); + }); + + const launched = spawnBackgroundSubagentTask({ + subagentType: "reflection", + prompt: "Reflect", + description: "Reflect on memory", + deps: { + spawnSubagentImpl, + addToMessageQueueImpl, + formatTaskNotificationImpl, + runSubagentStopHooksImpl, + generateSubagentIdImpl, + registerSubagentImpl, + completeSubagentImpl, + getSubagentSnapshotImpl, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const task = backgroundTasks.get(launched.taskId); + expect(task?.status).toBe("failed"); + expect(task?.error).toBe("subagent exploded"); + expect(queueMessages.length).toBe(1); + expect(runSubagentStopHooksImpl).toHaveBeenCalledWith( + "reflection", + "subagent-test-1", + false, + "subagent exploded", + undefined, + undefined, + ); + + const outputContent = readFileSync(launched.outputFile, "utf-8"); + expect(outputContent).toContain("[error] subagent exploded"); + }); +}); + +describe("waitForBackgroundSubagentLink", () => { + afterEach(() => { + clearAllSubagents(); + }); + + test("returns after agent URL becomes available", async () => { + registerSubagent("subagent-link-1", "reflection", "Reflect", "tc-1", true); + + setTimeout(() => { + updateSubagent("subagent-link-1", { + agentURL: "https://app.letta.com/agents/agent-123", + }); + }, 20); + + const start = Date.now(); + await waitForBackgroundSubagentLink("subagent-link-1", 300); + const elapsed = Date.now() - start; + + expect(elapsed).toBeGreaterThanOrEqual(10); + expect(elapsed).toBeLessThan(250); + }); + + test("times out when URL is unavailable", async () => { + registerSubagent("subagent-link-2", "reflection", "Reflect", "tc-2", true); + + const start = Date.now(); + await waitForBackgroundSubagentLink("subagent-link-2", 70); + const elapsed = Date.now() - start; + + expect(elapsed).toBeGreaterThanOrEqual(50); + }); +}); diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index 2a71a24..e021624 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -46,6 +46,7 @@ interface TaskArgs { // Valid subagent_types when deploying an existing agent const VALID_DEPLOY_TYPES = new Set(["explore", "general-purpose"]); +const BACKGROUND_STARTUP_POLL_MS = 50; type TaskRunResult = { agentId: string; @@ -56,6 +57,39 @@ type TaskRunResult = { totalTokens?: number; }; +export interface SpawnBackgroundSubagentTaskArgs { + subagentType: string; + prompt: string; + description: string; + model?: string; + toolCallId?: string; + existingAgentId?: string; + existingConversationId?: string; + maxTurns?: number; + /** + * Optional dependency overrides for tests. + * Production callers should not provide this. + */ + deps?: Partial; +} + +export interface SpawnBackgroundSubagentTaskResult { + taskId: string; + outputFile: string; + subagentId: string; +} + +interface SpawnBackgroundSubagentTaskDeps { + spawnSubagentImpl: typeof spawnSubagent; + addToMessageQueueImpl: typeof addToMessageQueue; + formatTaskNotificationImpl: typeof formatTaskNotification; + runSubagentStopHooksImpl: typeof runSubagentStopHooks; + generateSubagentIdImpl: typeof generateSubagentId; + registerSubagentImpl: typeof registerSubagent; + completeSubagentImpl: typeof completeSubagent; + getSubagentSnapshotImpl: typeof getSubagentSnapshot; +} + function buildTaskResultHeader( subagentType: string, result: Pick, @@ -101,6 +135,209 @@ function writeTaskTranscriptResult( ); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Wait briefly for a background subagent to publish its agent URL. + * This keeps Task mostly non-blocking while allowing static transcript rows + * to include an ADE link in the common case. + */ +export async function waitForBackgroundSubagentLink( + subagentId: string, + timeoutMs: number | null = null, + signal?: AbortSignal, +): Promise { + const deadline = + timeoutMs !== null && timeoutMs > 0 ? Date.now() + timeoutMs : null; + + while (true) { + if (signal?.aborted) { + return; + } + + const agent = getSubagentSnapshot().agents.find((a) => a.id === subagentId); + if (!agent) { + return; + } + if (agent.agentURL) { + return; + } + if (agent.status === "error" || agent.status === "completed") { + return; + } + if (deadline !== null && Date.now() >= deadline) { + return; + } + + await sleep(BACKGROUND_STARTUP_POLL_MS); + } +} + +/** + * Spawn a background subagent task and return task metadata immediately. + * Notification/hook behavior is identical to Task's background path. + */ +export function spawnBackgroundSubagentTask( + args: SpawnBackgroundSubagentTaskArgs, +): SpawnBackgroundSubagentTaskResult { + const { + subagentType, + prompt, + description, + model, + toolCallId, + existingAgentId, + existingConversationId, + maxTurns, + deps, + } = args; + + const spawnSubagentFn = deps?.spawnSubagentImpl ?? spawnSubagent; + const addToMessageQueueFn = deps?.addToMessageQueueImpl ?? addToMessageQueue; + const formatTaskNotificationFn = + deps?.formatTaskNotificationImpl ?? formatTaskNotification; + const runSubagentStopHooksFn = + deps?.runSubagentStopHooksImpl ?? runSubagentStopHooks; + const generateSubagentIdFn = + deps?.generateSubagentIdImpl ?? generateSubagentId; + const registerSubagentFn = deps?.registerSubagentImpl ?? registerSubagent; + const completeSubagentFn = deps?.completeSubagentImpl ?? completeSubagent; + const getSubagentSnapshotFn = + deps?.getSubagentSnapshotImpl ?? getSubagentSnapshot; + + const subagentId = generateSubagentIdFn(); + registerSubagentFn(subagentId, subagentType, description, toolCallId, true); + + const taskId = getNextTaskId(); + const outputFile = createBackgroundOutputFile(taskId); + const abortController = new AbortController(); + + const bgTask: BackgroundTask = { + description, + subagentType, + subagentId, + status: "running", + output: [], + startTime: new Date(), + outputFile, + abortController, + }; + backgroundTasks.set(taskId, bgTask); + writeTaskTranscriptStart(outputFile, description, subagentType); + + spawnSubagentFn( + subagentType, + prompt, + model, + subagentId, + abortController.signal, + existingAgentId, + existingConversationId, + maxTurns, + ) + .then((result) => { + bgTask.status = result.success ? "completed" : "failed"; + if (result.error) { + bgTask.error = result.error; + } + + const header = buildTaskResultHeader(subagentType, result); + writeTaskTranscriptResult(outputFile, result, header); + if (result.success) { + bgTask.output.push(result.report || ""); + } + + completeSubagentFn(subagentId, { + success: result.success, + error: result.error, + totalTokens: result.totalTokens, + }); + + const subagentSnapshot = getSubagentSnapshotFn(); + const toolUses = subagentSnapshot.agents.find( + (agent) => agent.id === subagentId, + )?.toolCalls.length; + const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); + + const fullResult = result.success + ? `${header}\n\n${result.report || ""}` + : result.error || "Subagent execution failed"; + const userCwd = process.env.USER_CWD || process.cwd(); + const { content: truncatedResult } = truncateByChars( + fullResult, + LIMITS.TASK_OUTPUT_CHARS, + "Task", + { workingDirectory: userCwd, toolName: "Task" }, + ); + + const notificationXml = formatTaskNotificationFn({ + taskId, + status: result.success ? "completed" : "failed", + summary: `Agent "${description}" ${result.success ? "completed" : "failed"}`, + result: truncatedResult, + outputFile, + usage: { + totalTokens: result.totalTokens, + toolUses, + durationMs, + }, + }); + addToMessageQueueFn({ kind: "task_notification", text: notificationXml }); + + runSubagentStopHooksFn( + subagentType, + subagentId, + result.success, + result.error, + result.agentId, + result.conversationId, + ).catch(() => { + // Silently ignore hook errors + }); + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + bgTask.status = "failed"; + bgTask.error = errorMessage; + appendToOutputFile(outputFile, `[error] ${errorMessage}\n`); + completeSubagentFn(subagentId, { success: false, error: errorMessage }); + + const subagentSnapshot = getSubagentSnapshotFn(); + const toolUses = subagentSnapshot.agents.find( + (agent) => agent.id === subagentId, + )?.toolCalls.length; + const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); + const notificationXml = formatTaskNotificationFn({ + taskId, + status: "failed", + summary: `Agent "${description}" failed`, + result: errorMessage, + outputFile, + usage: { + toolUses, + durationMs, + }, + }); + addToMessageQueueFn({ kind: "task_notification", text: notificationXml }); + + runSubagentStopHooksFn( + subagentType, + subagentId, + false, + errorMessage, + existingAgentId, + existingConversationId, + ).catch(() => { + // Silently ignore hook errors + }); + }); + + return { taskId, outputFile, subagentId }; +} + /** * Task tool - Launch a specialized subagent to handle complex tasks */ @@ -172,167 +409,30 @@ export async function task(args: TaskArgs): Promise { return `Error: When deploying an existing agent, subagent_type must be "explore" (read-only) or "general-purpose" (read-write). Got: "${subagent_type}"`; } - // Register subagent with state store for UI display - const subagentId = generateSubagentId(); const isBackground = args.run_in_background ?? false; - registerSubagent( - subagentId, - subagent_type, - description, - toolCallId, - isBackground, - ); // Handle background execution if (isBackground) { - const taskId = getNextTaskId(); - const outputFile = createBackgroundOutputFile(taskId); - - // Create abort controller for potential cancellation - const abortController = new AbortController(); - - // Register background task - const bgTask: BackgroundTask = { - description, + const { taskId, outputFile, subagentId } = spawnBackgroundSubagentTask({ subagentType: subagent_type, - subagentId, - status: "running", - output: [], - startTime: new Date(), - outputFile, - abortController, - }; - backgroundTasks.set(taskId, bgTask); - - // Write initial status to output file - writeTaskTranscriptStart(outputFile, description, subagent_type); - - // Fire-and-forget: run subagent without awaiting - spawnSubagent( - subagent_type, prompt, + description, model, - subagentId, - abortController.signal, - args.agent_id, - args.conversation_id, - args.max_turns, - ) - .then((result) => { - // Update background task state - bgTask.status = result.success ? "completed" : "failed"; - if (result.error) { - bgTask.error = result.error; - } + toolCallId, + existingAgentId: args.agent_id, + existingConversationId: args.conversation_id, + maxTurns: args.max_turns, + }); - // Build output header - const header = buildTaskResultHeader(subagent_type, result); + await waitForBackgroundSubagentLink(subagentId, null, signal); - // Write result to output file - writeTaskTranscriptResult(outputFile, result, header); - if (result.success) { - bgTask.output.push(result.report || ""); - } - - // Mark subagent as completed in state store - completeSubagent(subagentId, { - success: result.success, - error: result.error, - totalTokens: result.totalTokens, - }); - - const subagentSnapshot = getSubagentSnapshot(); - const toolUses = subagentSnapshot.agents.find( - (agent) => agent.id === subagentId, - )?.toolCalls.length; - const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); - - // Build and truncate the result (same as foreground path) - const fullResult = result.success - ? `${header}\n\n${result.report || ""}` - : result.error || "Subagent execution failed"; - const userCwd = process.env.USER_CWD || process.cwd(); - const { content: truncatedResult } = truncateByChars( - fullResult, - LIMITS.TASK_OUTPUT_CHARS, - "Task", - { workingDirectory: userCwd, toolName: "Task" }, - ); - - // Format and queue notification for auto-firing when idle - const notificationXml = formatTaskNotification({ - taskId, - status: result.success ? "completed" : "failed", - summary: `Agent "${description}" ${result.success ? "completed" : "failed"}`, - result: truncatedResult, - outputFile, - usage: { - totalTokens: result.totalTokens, - toolUses, - durationMs, - }, - }); - addToMessageQueue({ kind: "task_notification", text: notificationXml }); - - // Run SubagentStop hooks (fire-and-forget) - runSubagentStopHooks( - subagent_type, - subagentId, - result.success, - result.error, - result.agentId, - result.conversationId, - ).catch(() => { - // Silently ignore hook errors - }); - }) - .catch((error) => { - const errorMessage = - error instanceof Error ? error.message : String(error); - bgTask.status = "failed"; - bgTask.error = errorMessage; - appendToOutputFile(outputFile, `[error] ${errorMessage}\n`); - - // Mark subagent as completed with error - completeSubagent(subagentId, { success: false, error: errorMessage }); - - const subagentSnapshot = getSubagentSnapshot(); - const toolUses = subagentSnapshot.agents.find( - (agent) => agent.id === subagentId, - )?.toolCalls.length; - const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); - - // Format and queue notification for auto-firing when idle - const notificationXml = formatTaskNotification({ - taskId, - status: "failed", - summary: `Agent "${description}" failed`, - result: errorMessage, - outputFile, - usage: { - toolUses, - durationMs, - }, - }); - addToMessageQueue({ kind: "task_notification", text: notificationXml }); - - // Run SubagentStop hooks for error case - runSubagentStopHooks( - subagent_type, - subagentId, - false, - errorMessage, - args.agent_id, - args.conversation_id, - ).catch(() => { - // Silently ignore hook errors - }); - }); - - // Return immediately with task ID and output file return `Task running in background with ID: ${taskId}\nOutput file: ${outputFile}`; } + // Register subagent with state store for UI display (foreground path) + const subagentId = generateSubagentId(); + registerSubagent(subagentId, subagent_type, description, toolCallId, false); + // Foreground tasks now also write transcripts so users can inspect full output // even when inline content is truncated. const foregroundTaskId = getNextTaskId();