From f30dbf40dab01f806b208df028c9cafb2de31f30 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 18 Jan 2026 19:12:23 -0800 Subject: [PATCH] feat: deploy existing agents as subagents via Task tool (#591) Co-authored-by: Letta --- src/agent/subagents/manager.ts | 172 +++++++++++++++---- src/headless.ts | 95 +++++----- src/index.ts | 1 + src/skills/builtin/messaging-agents/SKILL.md | 10 ++ src/tools/descriptions/Task.md | 53 ++++++ src/tools/impl/Task.ts | 44 ++++- 6 files changed, 295 insertions(+), 80 deletions(-) diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index 12289c7..652cc3f 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -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 { // 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 ` +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. + + +`; +} + /** * 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 { 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; diff --git a/src/headless.ts b/src/headless.ts index 9a99230..4dcbbf4 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -104,6 +104,7 @@ export async function handleHeadlessCommand( "init-blocks": { type: "string" }, "base-tools": { type: "string" }, "from-af": { type: "string" }, + "no-skills": { type: "boolean" }, }, strict: false, allowPositionals: true, @@ -554,15 +555,33 @@ export async function handleHeadlessCommand( // Determine which conversation to use let conversationId: string; - // Only isolate blocks that actually exist on this agent - // If initBlocks is undefined, agent has default blocks (all ISOLATED_BLOCK_LABELS exist) - // If initBlocks is defined, only isolate blocks that are in both lists - const isolatedBlockLabels: string[] = - initBlocks === undefined - ? [...ISOLATED_BLOCK_LABELS] - : ISOLATED_BLOCK_LABELS.filter((label) => - initBlocks.includes(label as string), - ); + // Check flags early + const noSkillsFlag = values["no-skills"] as boolean | undefined; + const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; + + // Ensure skills blocks exist BEFORE conversation creation (for non-subagent, non-no-skills) + // This prevents "block not found" errors when creating conversations with isolated_block_labels + // Note: ensureSkillsBlocks already calls blocks.list internally, so no extra API call + if (!noSkillsFlag && !isSubagent) { + const createdBlocks = await ensureSkillsBlocks(agent.id); + if (createdBlocks.length > 0) { + console.log("Created missing skills blocks for agent compatibility"); + } + } + + // Determine which blocks to isolate for the conversation + let isolatedBlockLabels: string[] = []; + if (!noSkillsFlag) { + // After ensureSkillsBlocks, we know all standard blocks exist + // Use the full list, optionally filtered by initBlocks + isolatedBlockLabels = + initBlocks === undefined + ? [...ISOLATED_BLOCK_LABELS] + : ISOLATED_BLOCK_LABELS.filter((label) => + initBlocks.includes(label as string), + ); + } + // If --no-skills is set, isolatedBlockLabels stays empty (no isolation) if (specifiedConversationId) { if (specifiedConversationId === "default") { @@ -627,7 +646,6 @@ export async function handleHeadlessCommand( // Save session (agent + conversation) to both project and global settings // Skip for subagents - they shouldn't pollute the LRU settings - const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; if (!isSubagent) { await settingsManager.loadLocalProjectSettings(); settingsManager.setLocalLastSession( @@ -640,41 +658,40 @@ export async function handleHeadlessCommand( }); } - // Ensure the agent has the required skills blocks (for backwards compatibility) - const createdBlocks = await ensureSkillsBlocks(agent.id); - if (createdBlocks.length > 0) { - console.log("Created missing skills blocks for agent compatibility"); - } - // Set agent context for tools that need it (e.g., Skill tool, Task tool) setAgentContext(agent.id, skillsDirectory); - // Fire-and-forget: Initialize loaded skills flag (LET-7101) - // Don't await - this is just for the skill unload reminder - initializeLoadedSkillsFlag().catch(() => { - // Ignore errors - not critical - }); + // Skills-related fire-and-forget operations (skip for subagents/--no-skills) + if (!noSkillsFlag && !isSubagent) { + // Fire-and-forget: Initialize loaded skills flag (LET-7101) + // Don't await - this is just for the skill unload reminder + initializeLoadedSkillsFlag().catch(() => { + // Ignore errors - not critical + }); - // Fire-and-forget: Sync skills in background (LET-7101) - // This ensures new skills added after agent creation are available - // Don't await - proceed to message sending immediately - (async () => { - try { - const { syncSkillsToAgent, SKILLS_DIR } = await import("./agent/skills"); - const { join } = await import("node:path"); + // Fire-and-forget: Sync skills in background (LET-7101) + // This ensures new skills added after agent creation are available + // Don't await - proceed to message sending immediately + (async () => { + try { + const { syncSkillsToAgent, SKILLS_DIR } = await import( + "./agent/skills" + ); + const { join } = await import("node:path"); - const resolvedSkillsDirectory = - skillsDirectory || join(process.cwd(), SKILLS_DIR); + const resolvedSkillsDirectory = + skillsDirectory || join(process.cwd(), SKILLS_DIR); - await syncSkillsToAgent(client, agent.id, resolvedSkillsDirectory, { - skipIfUnchanged: true, - }); - } catch (error) { - console.warn( - `[skills] Background sync failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - })(); + await syncSkillsToAgent(client, agent.id, resolvedSkillsDirectory, { + skipIfUnchanged: true, + }); + } catch (error) { + console.warn( + `[skills] Background sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + })(); + } // Validate output format const outputFormat = diff --git a/src/index.ts b/src/index.ts index 27a3f2f..3480340 100755 --- a/src/index.ts +++ b/src/index.ts @@ -417,6 +417,7 @@ async function main(): Promise { skills: { type: "string" }, sleeptime: { type: "boolean" }, "from-af": { type: "string" }, + "no-skills": { type: "boolean" }, }, strict: true, allowPositionals: true, diff --git a/src/skills/builtin/messaging-agents/SKILL.md b/src/skills/builtin/messaging-agents/SKILL.md index 3445103..12b4a7f 100644 --- a/src/skills/builtin/messaging-agents/SKILL.md +++ b/src/skills/builtin/messaging-agents/SKILL.md @@ -30,6 +30,16 @@ This skill enables you to send messages to other agents on the same Letta server **Important:** This skill is for *communication* with other agents, not *delegation* of local work. The target agent runs in their own environment and cannot interact with your codebase. +**Need local access?** If you need the target agent to access your local environment (read/write files, run commands), use the Task tool instead to deploy them as a subagent: +```typescript +Task({ + agent_id: "agent-xxx", // Deploy this existing agent + subagent_type: "explore", // "explore" = read-only, "general-purpose" = read-write + prompt: "Look at the code in src/ and tell me about the architecture" +}) +``` +This gives the agent access to your codebase while running as a subagent. + ## Finding an Agent to Message If you don't have a specific agent ID, use these skills to find one: diff --git a/src/tools/descriptions/Task.md b/src/tools/descriptions/Task.md index a9727ff..d70e50c 100644 --- a/src/tools/descriptions/Task.md +++ b/src/tools/descriptions/Task.md @@ -14,6 +14,8 @@ Launch a subagent to perform a task. Parameters: - **prompt**: Detailed, self-contained instructions for the agent (agents cannot ask questions mid-execution) - **description**: Short 3-5 word summary for tracking - **model** (optional): Override the model for this agent +- **agent_id** (optional): Deploy an existing agent instead of creating a new one +- **conversation_id** (optional): Resume from an existing conversation ### Refresh Re-scan the `.letta/agents/` directories to discover new or updated custom subagents: @@ -45,6 +47,57 @@ Use this after creating or modifying custom subagent definitions. - **Parallel execution**: Launch multiple agents concurrently by calling Task multiple times in a single response - **Specify return format**: Tell agents exactly what information to include in their report +## Deploying an Existing Agent + +Instead of spawning a fresh subagent from a template, you can deploy an existing agent to work in your local codebase. + +### Access Levels (subagent_type) +Use subagent_type to control what tools the deployed agent can access: +- **explore**: Read-only access (Read, Glob, Grep) - safer for exploration tasks +- **general-purpose**: Full read-write access (Bash, Edit, Write, etc.) - for implementation tasks + +If subagent_type is not specified when deploying an existing agent, it defaults to "general-purpose". + +### Parameters + +- **agent_id**: The ID of an existing agent to deploy (e.g., "agent-abc123") + - Starts a new conversation with that agent + - The agent keeps its own system prompt and memory + - Tool access is controlled by subagent_type + +- **conversation_id**: Resume from an existing conversation (e.g., "conv-xyz789") + - Does NOT require agent_id (conversation IDs are unique and encode the agent) + - Continues from the conversation's existing message history + - Use this to continue context from: + - A prior Task tool invocation that returned a conversation_id + - A message thread started via the messaging-agents skill + +### Examples + +```typescript +// Deploy agent with read-only access +Task({ + agent_id: "agent-abc123", + subagent_type: "explore", + description: "Find auth code", + prompt: "Find all auth-related code in this codebase" +}) + +// Deploy agent with full access (default) +Task({ + agent_id: "agent-abc123", + description: "Fix auth bug", + prompt: "Fix the bug in auth.ts" +}) + +// Continue an existing conversation +Task({ + conversation_id: "conv-xyz789", + description: "Continue implementation", + prompt: "Now implement the fix we discussed" +}) +``` + ## Examples: ```typescript diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index 828a00f..74966b7 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -25,10 +25,15 @@ interface TaskArgs { 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 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"]); + /** * Task tool - Launch a specialized subagent to handle complex tasks */ @@ -61,18 +66,31 @@ export async function task(args: TaskArgs): Promise { return `Refreshed subagents list: found ${totalCount} total (${customCount} custom)${errorSuffix}`; } - // For run command, validate required parameters - validateRequiredParams( - args, - ["subagent_type", "prompt", "description"], - "Task", - ); + // Determine if deploying an existing agent + const isDeployingExisting = Boolean(args.agent_id || args.conversation_id); - // Extract validated params (guaranteed to exist after validateRequiredParams) - const subagent_type = args.subagent_type as string; + // 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 prompt = 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(); @@ -82,6 +100,11 @@ export async function task(args: TaskArgs): Promise { 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}"`; + } + // Register subagent with state store for UI display const subagentId = generateSubagentId(); registerSubagent(subagentId, subagent_type, description, toolCallId); @@ -93,6 +116,8 @@ export async function task(args: TaskArgs): Promise { model, subagentId, signal, + args.agent_id, + args.conversation_id, ); // Mark subagent as completed in state store @@ -111,6 +136,9 @@ export async function task(args: TaskArgs): Promise { const header = [ `subagent_type=${subagent_type}`, result.agentId ? `agent_id=${result.agentId}` : undefined, + result.conversationId + ? `conversation_id=${result.conversationId}` + : undefined, ] .filter(Boolean) .join(" ");