feat: deploy existing agents as subagents via Task tool (#591)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { getAllSubagentConfigs, type SubagentConfig } from ".";
|
||||
*/
|
||||
export interface SubagentResult {
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
report: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -46,6 +47,7 @@ export interface SubagentResult {
|
||||
*/
|
||||
interface ExecutionState {
|
||||
agentId: string | null;
|
||||
conversationId: string | null;
|
||||
finalResult: string | null;
|
||||
finalError: string | null;
|
||||
resultStats: { durationMs: number; totalTokens: number } | null;
|
||||
@@ -106,7 +108,7 @@ function recordToolCall(
|
||||
* Handle an init event from the subagent stream
|
||||
*/
|
||||
function handleInitEvent(
|
||||
event: { agent_id?: string },
|
||||
event: { agent_id?: string; conversation_id?: string },
|
||||
state: ExecutionState,
|
||||
baseURL: string,
|
||||
subagentId: string,
|
||||
@@ -116,6 +118,9 @@ function handleInitEvent(
|
||||
const agentURL = `${baseURL}/agents/${event.agent_id}`;
|
||||
updateSubagent(subagentId, { agentURL });
|
||||
}
|
||||
if (event.conversation_id) {
|
||||
state.conversationId = event.conversation_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,21 +311,40 @@ function parseResultFromStdout(
|
||||
function buildSubagentArgs(
|
||||
type: string,
|
||||
config: SubagentConfig,
|
||||
model: string,
|
||||
model: string | null,
|
||||
userPrompt: string,
|
||||
existingAgentId?: string,
|
||||
existingConversationId?: string,
|
||||
preloadedSkillsContent?: string,
|
||||
): string[] {
|
||||
const args: string[] = [
|
||||
"--new-agent",
|
||||
"--system",
|
||||
type,
|
||||
"--model",
|
||||
model,
|
||||
"-p",
|
||||
userPrompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
];
|
||||
const args: string[] = [];
|
||||
const isDeployingExisting = Boolean(
|
||||
existingAgentId || existingConversationId,
|
||||
);
|
||||
|
||||
if (isDeployingExisting) {
|
||||
// Deploy existing agent/conversation
|
||||
if (existingConversationId) {
|
||||
// conversation_id is sufficient (headless derives agent from it)
|
||||
args.push("--conv", existingConversationId);
|
||||
} else if (existingAgentId) {
|
||||
// agent_id only - headless creates new conversation
|
||||
args.push("--agent", existingAgentId);
|
||||
}
|
||||
// Don't pass --system (existing agent keeps its prompt)
|
||||
// Don't pass --model (existing agent keeps its model)
|
||||
// Skip skills block operations (existing agent may not have standard blocks)
|
||||
args.push("--no-skills");
|
||||
} else {
|
||||
// Create new agent (original behavior)
|
||||
args.push("--new-agent", "--system", type);
|
||||
if (model) {
|
||||
args.push("--model", model);
|
||||
}
|
||||
}
|
||||
|
||||
args.push("-p", userPrompt);
|
||||
args.push("--output-format", "stream-json");
|
||||
|
||||
// Use subagent's configured permission mode, or inherit from parent
|
||||
const subagentMode = config.permissionMode;
|
||||
@@ -330,31 +354,40 @@ function buildSubagentArgs(
|
||||
args.push("--permission-mode", modeToUse);
|
||||
}
|
||||
|
||||
// Inherit permission rules from parent (CLI + session rules)
|
||||
// Build list of auto-approved tools:
|
||||
// 1. Inherit from parent (CLI + session rules)
|
||||
// 2. Add subagent's allowed tools (so they don't hang on approvals)
|
||||
const parentAllowedTools = cliPermissions.getAllowedTools();
|
||||
const sessionAllowRules = sessionPermissions.getRules().allow || [];
|
||||
const subagentTools =
|
||||
config.allowedTools !== "all" && Array.isArray(config.allowedTools)
|
||||
? config.allowedTools
|
||||
: [];
|
||||
const combinedAllowedTools = [
|
||||
...new Set([...parentAllowedTools, ...sessionAllowRules]),
|
||||
...new Set([...parentAllowedTools, ...sessionAllowRules, ...subagentTools]),
|
||||
];
|
||||
if (combinedAllowedTools.length > 0) {
|
||||
args.push("--allowedTools", combinedAllowedTools.join(","));
|
||||
}
|
||||
|
||||
const parentDisallowedTools = cliPermissions.getDisallowedTools();
|
||||
if (parentDisallowedTools.length > 0) {
|
||||
args.push("--disallowedTools", parentDisallowedTools.join(","));
|
||||
}
|
||||
|
||||
// Add memory block filtering if specified
|
||||
if (config.memoryBlocks === "none") {
|
||||
args.push("--init-blocks", "none");
|
||||
} else if (
|
||||
Array.isArray(config.memoryBlocks) &&
|
||||
config.memoryBlocks.length > 0
|
||||
) {
|
||||
args.push("--init-blocks", config.memoryBlocks.join(","));
|
||||
// Add memory block filtering if specified (only for new agents)
|
||||
if (!isDeployingExisting) {
|
||||
if (config.memoryBlocks === "none") {
|
||||
args.push("--init-blocks", "none");
|
||||
} else if (
|
||||
Array.isArray(config.memoryBlocks) &&
|
||||
config.memoryBlocks.length > 0
|
||||
) {
|
||||
args.push("--init-blocks", config.memoryBlocks.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool filtering if specified
|
||||
// Add tool filtering if specified (applies to both new and existing agents)
|
||||
if (
|
||||
config.allowedTools !== "all" &&
|
||||
Array.isArray(config.allowedTools) &&
|
||||
@@ -363,8 +396,8 @@ function buildSubagentArgs(
|
||||
args.push("--tools", config.allowedTools.join(","));
|
||||
}
|
||||
|
||||
// Add pre-loaded skills content if provided
|
||||
if (preloadedSkillsContent) {
|
||||
// Add pre-loaded skills content if provided (only for new agents)
|
||||
if (!isDeployingExisting && preloadedSkillsContent) {
|
||||
args.push("--block-value", `loaded_skills=${preloadedSkillsContent}`);
|
||||
}
|
||||
|
||||
@@ -377,12 +410,14 @@ function buildSubagentArgs(
|
||||
async function executeSubagent(
|
||||
type: string,
|
||||
config: SubagentConfig,
|
||||
model: string,
|
||||
model: string | null,
|
||||
userPrompt: string,
|
||||
baseURL: string,
|
||||
subagentId: string,
|
||||
isRetry = false,
|
||||
signal?: AbortSignal,
|
||||
existingAgentId?: string,
|
||||
existingConversationId?: string,
|
||||
): Promise<SubagentResult> {
|
||||
// Check if already aborted before starting
|
||||
if (signal?.aborted) {
|
||||
@@ -395,7 +430,9 @@ async function executeSubagent(
|
||||
}
|
||||
|
||||
// Update the state with the model being used (may differ on retry/fallback)
|
||||
updateSubagent(subagentId, { model });
|
||||
if (model) {
|
||||
updateSubagent(subagentId, { model });
|
||||
}
|
||||
|
||||
try {
|
||||
// Pre-load skills if configured
|
||||
@@ -412,12 +449,23 @@ async function executeSubagent(
|
||||
config,
|
||||
model,
|
||||
userPrompt,
|
||||
existingAgentId,
|
||||
existingConversationId,
|
||||
preloadedSkillsContent,
|
||||
);
|
||||
|
||||
// Spawn Letta Code in headless mode.
|
||||
// Some environments may have a different `letta` binary earlier in PATH.
|
||||
const lettaCmd = process.env.LETTA_CODE_BIN || "letta";
|
||||
// Use the same binary as the current process, with fallbacks:
|
||||
// 1. LETTA_CODE_BIN env var (explicit override)
|
||||
// 2. Current process argv[1] if it's a .js file (built letta.js)
|
||||
// 3. ./letta.js if running from dev (src/index.ts)
|
||||
// 4. "letta" (global install)
|
||||
const currentScript = process.argv[1] || "";
|
||||
const lettaCmd =
|
||||
process.env.LETTA_CODE_BIN ||
|
||||
(currentScript.endsWith(".js") ? currentScript : null) ||
|
||||
(currentScript.includes("src/index.ts") ? "./letta.js" : null) ||
|
||||
"letta";
|
||||
// Pass parent agent ID so subagents can access parent's context (e.g., search history)
|
||||
let parentAgentId: string | undefined;
|
||||
try {
|
||||
@@ -450,7 +498,8 @@ async function executeSubagent(
|
||||
|
||||
// Initialize execution state
|
||||
const state: ExecutionState = {
|
||||
agentId: null,
|
||||
agentId: existingAgentId || null,
|
||||
conversationId: existingConversationId || null,
|
||||
finalResult: null,
|
||||
finalError: null,
|
||||
resultStats: null,
|
||||
@@ -486,6 +535,7 @@ async function executeSubagent(
|
||||
if (wasAborted) {
|
||||
return {
|
||||
agentId: state.agentId || "",
|
||||
conversationId: state.conversationId || undefined,
|
||||
report: "",
|
||||
success: false,
|
||||
error: INTERRUPTED_BY_USER,
|
||||
@@ -516,6 +566,7 @@ async function executeSubagent(
|
||||
|
||||
return {
|
||||
agentId: state.agentId || "",
|
||||
conversationId: state.conversationId || undefined,
|
||||
report: "",
|
||||
success: false,
|
||||
error: stderr || `Subagent exited with code ${exitCode}`,
|
||||
@@ -526,6 +577,7 @@ async function executeSubagent(
|
||||
if (state.finalResult !== null) {
|
||||
return {
|
||||
agentId: state.agentId || "",
|
||||
conversationId: state.conversationId || undefined,
|
||||
report: state.finalResult,
|
||||
success: !state.finalError,
|
||||
error: state.finalError || undefined,
|
||||
@@ -537,6 +589,7 @@ async function executeSubagent(
|
||||
if (state.finalError) {
|
||||
return {
|
||||
agentId: state.agentId || "",
|
||||
conversationId: state.conversationId || undefined,
|
||||
report: "",
|
||||
success: false,
|
||||
error: state.finalError,
|
||||
@@ -576,6 +629,28 @@ function getBaseURL(): string {
|
||||
return baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a system reminder prefix for deployed agents
|
||||
*/
|
||||
function buildDeploySystemReminder(
|
||||
senderAgentName: string,
|
||||
senderAgentId: string,
|
||||
subagentType: string,
|
||||
): string {
|
||||
const toolDescription =
|
||||
subagentType === "explore"
|
||||
? "read-only tools (Read, Glob, Grep)"
|
||||
: "local tools (Bash, Read, Write, Edit, etc.)";
|
||||
|
||||
return `<system-reminder>
|
||||
This task is from "${senderAgentName}" (agent ID: ${senderAgentId}), which deployed you as a subagent inside the Letta Code CLI (docs.letta.com/letta-code).
|
||||
You have access to ${toolDescription} in their codebase.
|
||||
Your final message will be returned to the caller.
|
||||
</system-reminder>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a subagent and execute it autonomously
|
||||
*
|
||||
@@ -584,6 +659,8 @@ function getBaseURL(): string {
|
||||
* @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
|
||||
* @param existingAgentId - Optional ID of an existing agent to deploy
|
||||
* @param existingConversationId - Optional conversation ID to resume
|
||||
*/
|
||||
export async function spawnSubagent(
|
||||
type: string,
|
||||
@@ -591,6 +668,8 @@ export async function spawnSubagent(
|
||||
userModel: string | undefined,
|
||||
subagentId: string,
|
||||
signal?: AbortSignal,
|
||||
existingAgentId?: string,
|
||||
existingConversationId?: string,
|
||||
): Promise<SubagentResult> {
|
||||
const allConfigs = await getAllSubagentConfigs();
|
||||
const config = allConfigs[type];
|
||||
@@ -604,19 +683,46 @@ export async function spawnSubagent(
|
||||
};
|
||||
}
|
||||
|
||||
const model = userModel || config.recommendedModel;
|
||||
const isDeployingExisting = Boolean(
|
||||
existingAgentId || existingConversationId,
|
||||
);
|
||||
|
||||
// For existing agents, don't override model; for new agents, use provided or config default
|
||||
const model = isDeployingExisting
|
||||
? null
|
||||
: userModel || config.recommendedModel;
|
||||
const baseURL = getBaseURL();
|
||||
|
||||
// Build the prompt with system reminder for deployed agents
|
||||
let finalPrompt = prompt;
|
||||
if (isDeployingExisting) {
|
||||
try {
|
||||
const parentAgentId = getCurrentAgentId();
|
||||
const client = await getClient();
|
||||
const parentAgent = await client.agents.retrieve(parentAgentId);
|
||||
const systemReminder = buildDeploySystemReminder(
|
||||
parentAgent.name,
|
||||
parentAgentId,
|
||||
type,
|
||||
);
|
||||
finalPrompt = systemReminder + prompt;
|
||||
} catch {
|
||||
// If we can't get parent agent info, proceed without the reminder
|
||||
}
|
||||
}
|
||||
|
||||
// Execute subagent - state updates are handled via the state store
|
||||
const result = await executeSubagent(
|
||||
type,
|
||||
config,
|
||||
model,
|
||||
prompt,
|
||||
finalPrompt,
|
||||
baseURL,
|
||||
subagentId,
|
||||
false,
|
||||
signal,
|
||||
existingAgentId,
|
||||
existingConversationId,
|
||||
);
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user