feat: auto-launch reflection via shared background task helper (#924)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string | undefined | null>
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Simple status line */}
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
{agent.status === "error" ? (
|
||||
<Text color={colors.subagent.error}>Error</Text>
|
||||
) : isComplete ? (
|
||||
<Text dimColor>Done</Text>
|
||||
) : agent.isBackground ? (
|
||||
<Text dimColor>Running in the background</Text>
|
||||
) : (
|
||||
<Text dimColor>Running...</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!hideBackgroundStatusLine && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
{agent.status === "error" ? (
|
||||
<Text color={colors.subagent.error}>Error</Text>
|
||||
) : isComplete ? (
|
||||
<Text dimColor>Done</Text>
|
||||
) : agent.isBackground ? (
|
||||
<Text dimColor>Running in the background</Text>
|
||||
) : (
|
||||
<Text dimColor>Running...</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -177,10 +182,12 @@ const AgentRow = memo(
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
{showStats && (
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -215,61 +222,63 @@ const AgentRow = memo(
|
||||
})}
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
{!hideBackgroundStatusLine && (
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : agent.isBackground ? (
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
<Text dimColor>{" Running in the background"}</Text>
|
||||
</Text>
|
||||
) : lastTool ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : agent.isBackground ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Running in the background"}</Text>
|
||||
</>
|
||||
) : lastTool ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Starting..."}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Starting..."}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
{showStats && (
|
||||
<Text dimColor>
|
||||
{" · "}
|
||||
{stats}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -115,42 +120,44 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
||||
)}
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" && !agent.isBackground ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
{!hideBackgroundStatusLine && (
|
||||
<Box flexDirection="row">
|
||||
{agent.status === "completed" && !agent.isBackground ? (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>
|
||||
{" "}
|
||||
{continueChar}
|
||||
</Text>
|
||||
<Text dimColor>{" Running in the background"}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Text dimColor>{" Running in the background"}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
10
src/tests/agent/subagent-builtins.test.ts
Normal file
10
src/tests/agent/subagent-builtins.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
22
src/tests/cli/reflection-auto-launch-wiring.test.ts
Normal file
22
src/tests/cli/reflection-auto-launch-wiring.test.ts
Normal file
@@ -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({");
|
||||
});
|
||||
});
|
||||
217
src/tests/tools/task-background-helper.test.ts
Normal file
217
src/tests/tools/task-background-helper.test.ts
Normal file
@@ -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) => "<task-notification/>",
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<SpawnBackgroundSubagentTaskDeps>;
|
||||
}
|
||||
|
||||
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<TaskRunResult, "agentId" | "conversationId">,
|
||||
@@ -101,6 +135,209 @@ function writeTaskTranscriptResult(
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user