Files
letta-code/src/tools/impl/Task.ts
2026-03-11 18:39:45 -07:00

592 lines
18 KiB
TypeScript

/**
* Task tool implementation
*
* Spawns specialized subagents to handle complex, multi-step tasks autonomously.
* Supports both built-in subagent types and custom subagents defined in .letta/agents/.
*/
import {
clearSubagentConfigCache,
discoverSubagents,
getAllSubagentConfigs,
} from "../../agent/subagents";
import { spawnSubagent } from "../../agent/subagents/manager";
import { addToMessageQueue } from "../../cli/helpers/messageQueueBridge.js";
import {
completeSubagent,
generateSubagentId,
getSnapshot as getSubagentSnapshot,
registerSubagent,
} from "../../cli/helpers/subagentState.js";
import { formatTaskNotification } from "../../cli/helpers/taskNotifications.js";
import { runSubagentStopHooks } from "../../hooks";
import {
appendToOutputFile,
type BackgroundTask,
backgroundTasks,
createBackgroundOutputFile,
getNextTaskId,
} from "./process_manager.js";
import { LIMITS, truncateByChars } from "./truncation.js";
import { validateRequiredParams } from "./validation";
interface TaskArgs {
command?: "run" | "refresh";
subagent_type?: string;
prompt?: string;
description?: string;
model?: string;
agent_id?: string; // Deploy an existing agent instead of creating new
conversation_id?: string; // Resume from an existing conversation
run_in_background?: boolean; // Run the task in background
max_turns?: number; // Maximum number of agentic turns
toolCallId?: string; // Injected by executeTool for linking subagent to parent tool call
signal?: AbortSignal; // Injected by executeTool for interruption handling
}
// 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;
conversationId?: string;
report: string;
success: boolean;
error?: string;
totalTokens?: number;
};
export interface SpawnBackgroundSubagentTaskArgs {
subagentType: string;
prompt: string;
description: string;
model?: string;
toolCallId?: string;
existingAgentId?: string;
existingConversationId?: string;
maxTurns?: number;
/**
* When true, skip injecting the completion notification into the primary
* agent's message queue and hide from SubagentGroupDisplay.
* Use `onComplete` to show a user-facing notification without leaking
* into the agent's context.
*/
silentCompletion?: boolean;
/**
* Called after the subagent finishes (success or failure).
* Runs regardless of `silentCompletion` and is awaited before
* completion notifications/hooks continue.
*/
onComplete?: (result: {
success: boolean;
error?: string;
}) => void | Promise<void>;
/**
* 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">,
): string {
return [
`subagent_type=${subagentType}`,
result.agentId ? `agent_id=${result.agentId}` : undefined,
result.conversationId
? `conversation_id=${result.conversationId}`
: undefined,
]
.filter(Boolean)
.join(" ");
}
function writeTaskTranscriptStart(
outputFile: string,
description: string,
subagentType: string,
): void {
appendToOutputFile(
outputFile,
`[Task started: ${description}]\n[subagent_type: ${subagentType}]\n\n`,
);
}
function writeTaskTranscriptResult(
outputFile: string,
result: TaskRunResult,
header: string,
): void {
if (result.success) {
appendToOutputFile(
outputFile,
`${header}\n\n${result.report}\n\n[Task completed]\n`,
);
return;
}
appendToOutputFile(
outputFile,
`[error] ${result.error || "Subagent execution failed"}\n\n[Task failed]\n`,
);
}
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,
silentCompletion,
onComplete,
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,
silentCompletion,
);
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);
// Intentionally fire-and-forget: background tasks own their lifecycle and
// capture failures in task state/transcripts instead of surfacing a promise
// back to the caller.
spawnSubagentFn(
subagentType,
prompt,
model,
subagentId,
abortController.signal,
existingAgentId,
existingConversationId,
maxTurns,
)
.then(async (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,
});
try {
await onComplete?.({ success: result.success, error: result.error });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
appendToOutputFile(outputFile, `[onComplete error] ${errorMessage}\n`);
}
if (!silentCompletion) {
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(async (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 });
try {
await onComplete?.({ success: false, error: errorMessage });
} catch (onCompleteError) {
const callbackMessage =
onCompleteError instanceof Error
? onCompleteError.message
: String(onCompleteError);
appendToOutputFile(
outputFile,
`[onComplete error] ${callbackMessage}\n`,
);
}
if (!silentCompletion) {
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
*/
export async function task(args: TaskArgs): Promise<string> {
const { command = "run", model, toolCallId, signal } = args;
// Handle refresh command - re-discover subagents from .letta/agents/ directories
if (command === "refresh") {
// Clear the cache to force re-discovery
clearSubagentConfigCache();
// Discover subagents from global and project directories
const { subagents, errors } = await discoverSubagents();
// Get all configs (builtins + discovered) to report accurate count
const allConfigs = await getAllSubagentConfigs();
const totalCount = Object.keys(allConfigs).length;
const customCount = subagents.length;
// Log any errors
if (errors.length > 0) {
for (const error of errors) {
console.warn(
`Subagent discovery error: ${error.path}: ${error.message}`,
);
}
}
const errorSuffix = errors.length > 0 ? `, ${errors.length} error(s)` : "";
return `Refreshed subagents list: found ${totalCount} total (${customCount} custom)${errorSuffix}`;
}
// Determine if deploying an existing agent
const isDeployingExisting = Boolean(args.agent_id || args.conversation_id);
// Validate required parameters based on mode
if (isDeployingExisting) {
// Deploying existing agent: prompt and description required, subagent_type optional
validateRequiredParams(args, ["prompt", "description"], "Task");
} else {
// Creating new agent: subagent_type, prompt, and description required
validateRequiredParams(
args,
["subagent_type", "prompt", "description"],
"Task",
);
}
// Extract validated params
const inputPrompt = args.prompt as string;
const description = args.description as string;
// For existing agents, default subagent_type to "general-purpose" for permissions
const subagent_type = isDeployingExisting
? args.subagent_type || "general-purpose"
: (args.subagent_type as string);
// Get all available subagent configs (built-in + custom)
const allConfigs = await getAllSubagentConfigs();
// Validate subagent type
if (!(subagent_type in allConfigs)) {
const available = Object.keys(allConfigs).join(", ");
return `Error: Invalid subagent type "${subagent_type}". Available types: ${available}`;
}
// For existing agents, only allow explore or general-purpose
if (isDeployingExisting && !VALID_DEPLOY_TYPES.has(subagent_type)) {
return `Error: When deploying an existing agent, subagent_type must be "explore" (read-only) or "general-purpose" (read-write). Got: "${subagent_type}"`;
}
const prompt = inputPrompt;
const isBackground = args.run_in_background ?? false;
// Handle background execution
if (isBackground) {
const { taskId, outputFile, subagentId } = spawnBackgroundSubagentTask({
subagentType: subagent_type,
prompt,
description,
model,
toolCallId,
existingAgentId: args.agent_id,
existingConversationId: args.conversation_id,
maxTurns: args.max_turns,
});
await waitForBackgroundSubagentLink(subagentId, null, signal);
// Extract Letta agent ID from subagent state (available after link resolves)
const linkedAgent = getSubagentSnapshot().agents.find(
(a) => a.id === subagentId,
);
const agentId = linkedAgent?.agentURL?.split("/agents/")[1] ?? null;
const agentIdLine = agentId ? `\nAgent ID: ${agentId}` : "";
return `Task running in background with task ID: ${taskId}${agentIdLine}\nOutput file: ${outputFile}\n\nYou will be notified automatically when this task completes — a <task-notification> message will be delivered with the result. No need to poll, sleep-wait, or check the output file. Just continue with your current work.`;
}
// 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();
const outputFile = createBackgroundOutputFile(foregroundTaskId);
writeTaskTranscriptStart(outputFile, description, subagent_type);
try {
const result = await spawnSubagent(
subagent_type,
prompt,
model,
subagentId,
signal,
args.agent_id,
args.conversation_id,
args.max_turns,
);
// Mark subagent as completed in state store
completeSubagent(subagentId, {
success: result.success,
error: result.error,
totalTokens: result.totalTokens,
});
// Run SubagentStop hooks (fire-and-forget)
runSubagentStopHooks(
subagent_type,
subagentId,
result.success,
result.error,
result.agentId,
result.conversationId,
).catch(() => {
// Silently ignore hook errors
});
if (!result.success) {
const errorMessage = result.error || "Subagent execution failed";
const failedResult: TaskRunResult = {
...result,
error: errorMessage,
};
writeTaskTranscriptResult(outputFile, failedResult, "");
return `Error: ${errorMessage}\nOutput file: ${outputFile}`;
}
// Include stable subagent metadata so orchestrators can attribute results.
// Keep the tool return type as a string for compatibility.
const header = buildTaskResultHeader(subagent_type, result);
const fullOutput = `${header}\n\n${result.report}`;
writeTaskTranscriptResult(outputFile, result, header);
const userCwd = process.env.USER_CWD || process.cwd();
// Apply truncation to prevent excessive token usage (same pattern as Bash tool)
const { content: truncatedOutput } = truncateByChars(
fullOutput,
LIMITS.TASK_OUTPUT_CHARS,
"Task",
{ workingDirectory: userCwd, toolName: "Task" },
);
return `${truncatedOutput}\nOutput file: ${outputFile}`;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
completeSubagent(subagentId, { success: false, error: errorMessage });
// Run SubagentStop hooks for error case (fire-and-forget)
runSubagentStopHooks(
subagent_type,
subagentId,
false,
errorMessage,
args.agent_id,
args.conversation_id,
).catch(() => {
// Silently ignore hook errors
});
appendToOutputFile(
outputFile,
`[error] ${errorMessage}\n\n[Task failed]\n`,
);
return `Error: ${errorMessage}\nOutput file: ${outputFile}`;
}
}