feat: deploy existing agents as subagents via Task tool (#591)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-18 19:12:23 -08:00
committed by GitHub
parent 7905260fc9
commit f30dbf40da
6 changed files with 295 additions and 80 deletions

View File

@@ -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;

View File

@@ -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 =

View File

@@ -417,6 +417,7 @@ async function main(): Promise<void> {
skills: { type: "string" },
sleeptime: { type: "boolean" },
"from-af": { type: "string" },
"no-skills": { type: "boolean" },
},
strict: true,
allowPositionals: true,

View File

@@ -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:

View File

@@ -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

View File

@@ -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<string> {
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<string> {
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<string> {
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<string> {
const header = [
`subagent_type=${subagent_type}`,
result.agentId ? `agent_id=${result.agentId}` : undefined,
result.conversationId
? `conversation_id=${result.conversationId}`
: undefined,
]
.filter(Boolean)
.join(" ");