feat: Stateless subagents (#127)

This commit is contained in:
Devansh Jain
2025-12-15 20:16:25 -08:00
committed by GitHub
parent 560f3591ad
commit ae54666a98
31 changed files with 1855 additions and 283 deletions

View File

@@ -8,9 +8,9 @@
* when users install via npm/npx. Bun can still run this file.
*/
import { spawn } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
import { spawn } from "child_process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

View File

@@ -6,7 +6,7 @@
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
@@ -56,4 +56,6 @@ await Bun.$`chmod +x letta.js`;
console.log("✅ Build complete!");
console.log(` Output: letta.js`);
console.log(` Size: ${(await Bun.file(outputPath).size / 1024).toFixed(0)}KB`);
console.log(
` Size: ${((await Bun.file(outputPath).size) / 1024).toFixed(0)}KB`,
);

View File

@@ -1,13 +1,10 @@
/**
* Agent context module - provides global access to current agent state
* This allows tools to access the current agent ID and client
* This allows tools to access the current agent ID without threading it through params.
*/
import type Letta from "@letta-ai/letta-client";
interface AgentContext {
agentId: string | null;
client: Letta | null;
skillsDirectory: string | null;
hasLoadedSkills: boolean;
}
@@ -25,7 +22,6 @@ function getContext(): AgentContext {
if (!global[CONTEXT_KEY]) {
global[CONTEXT_KEY] = {
agentId: null,
client: null,
skillsDirectory: null,
hasLoadedSkills: false,
};
@@ -38,19 +34,23 @@ const context = getContext();
/**
* Set the current agent context
* @param agentId - The agent ID
* @param client - The Letta client instance
* @param skillsDirectory - Optional skills directory path
*/
export function setAgentContext(
agentId: string,
client: Letta,
skillsDirectory?: string,
): void {
context.agentId = agentId;
context.client = client;
context.skillsDirectory = skillsDirectory || null;
}
/**
* Set the current agent ID in context (simplified version for compatibility)
*/
export function setCurrentAgentId(agentId: string): void {
context.agentId = agentId;
}
/**
* Get the current agent ID
* @throws Error if no agent context is set
@@ -62,17 +62,6 @@ export function getCurrentAgentId(): string {
return context.agentId;
}
/**
* Get the current Letta client
* @throws Error if no agent context is set
*/
export function getCurrentClient(): Letta {
if (!context.client) {
throw new Error("No agent context set. Client is required.");
}
return context.client;
}
/**
* Get the skills directory path
* @returns The skills directory path or null if not set
@@ -102,12 +91,14 @@ export function setHasLoadedSkills(loaded: boolean): void {
* Should be called after setAgentContext to sync the cached state
*/
export async function initializeLoadedSkillsFlag(): Promise<void> {
if (!context.client || !context.agentId) {
if (!context.agentId) {
return;
}
try {
const loadedSkillsBlock = await context.client.agents.blocks.retrieve(
const { getClient } = await import("./client");
const client = await getClient();
const loadedSkillsBlock = await client.agents.blocks.retrieve(
"loaded_skills",
{ agent_id: context.agentId },
);
@@ -119,13 +110,3 @@ export async function initializeLoadedSkillsFlag(): Promise<void> {
context.hasLoadedSkills = false;
}
}
/**
* Clear the agent context (useful for cleanup)
*/
export function clearAgentContext(): void {
context.agentId = null;
context.client = null;
context.skillsDirectory = null;
context.hasLoadedSkills = false;
}

View File

@@ -16,7 +16,7 @@ import {
resolveModel,
} from "./model";
import { updateAgentLLMConfig } from "./modify";
import { SYSTEM_PROMPT, SYSTEM_PROMPTS } from "./promptAssets";
import { resolveSystemPrompt } from "./promptAssets";
import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime";
import { discoverSkills, formatSkillsForMemory, SKILLS_DIR } from "./skills";
@@ -52,7 +52,7 @@ export async function createAgent(
skillsDirectory?: string,
parallelToolCalls = true,
enableSleeptime = false,
systemPromptId?: string,
systemPrompt?: string,
initBlocks?: string[],
baseTools?: string[],
) {
@@ -201,16 +201,13 @@ export async function createAgent(
const modelUpdateArgs = getModelUpdateArgs(modelHandle);
const contextWindow = (modelUpdateArgs?.context_window as number) || 200_000;
// Resolve system prompt (use specified ID or default)
const systemPrompt = systemPromptId
? (SYSTEM_PROMPTS.find((p) => p.id === systemPromptId)?.content ??
SYSTEM_PROMPT)
: SYSTEM_PROMPT;
// Resolve system prompt (ID, subagent name, or literal content)
const resolvedSystemPrompt = await resolveSystemPrompt(systemPrompt);
// Create agent with all block IDs (existing + newly created)
const agent = await client.agents.create({
agent_type: "letta_v1_agent" as AgentType,
system: systemPrompt,
system: resolvedSystemPrompt,
name,
description: `Letta Code agent created in ${process.cwd()}`,
embedding: embeddingModel,

View File

@@ -6,9 +6,13 @@
import type { CreateBlock } from "@letta-ai/letta-client/resources/blocks/blocks";
import { MEMORY_PROMPTS } from "./promptAssets";
/**
* Block labels that are stored globally (shared across all projects).
*/
export const GLOBAL_BLOCK_LABELS = ["persona", "human"] as const;
/**
* Block labels that are stored per-project (local to the current directory).
* All other blocks are stored globally (shared across all projects).
*/
export const PROJECT_BLOCK_LABELS = [
"project",
@@ -16,6 +20,19 @@ export const PROJECT_BLOCK_LABELS = [
"loaded_skills",
] as const;
/**
* All available memory block labels (derived from global + project blocks)
*/
export const MEMORY_BLOCK_LABELS = [
...GLOBAL_BLOCK_LABELS,
...PROJECT_BLOCK_LABELS,
] as const;
/**
* Type for memory block labels
*/
export type MemoryBlockLabel = (typeof MEMORY_BLOCK_LABELS)[number];
/**
* Block labels that should be read-only (agent cannot modify via memory tools).
* These blocks are managed by specific tools (e.g., Skill tool for skills/loaded_skills).
@@ -66,13 +83,7 @@ function parseMdxFrontmatter(content: string): {
async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
const memoryBlocks: CreateBlock[] = [];
const mdxFiles = [
"persona_empty.mdx",
"human.mdx",
"project.mdx",
"skills.mdx",
"loaded_skills.mdx",
];
const mdxFiles = MEMORY_BLOCK_LABELS.map((label) => `${label}.mdx`);
for (const filename of mdxFiles) {
try {

View File

@@ -10,7 +10,7 @@ import lettaCodexPrompt from "./prompts/letta_codex.md";
import lettaGeminiPrompt from "./prompts/letta_gemini.md";
import loadedSkillsPrompt from "./prompts/loaded_skills.mdx";
import personaPrompt from "./prompts/persona.mdx";
import personaEmptyPrompt from "./prompts/persona_empty.mdx";
import personaClaudePrompt from "./prompts/persona_claude.mdx";
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
import planModeReminder from "./prompts/plan_mode_reminder.txt";
import projectPrompt from "./prompts/project.mdx";
@@ -30,13 +30,13 @@ export const REMEMBER_PROMPT = rememberPrompt;
export const MEMORY_PROMPTS: Record<string, string> = {
"persona.mdx": personaPrompt,
"persona_empty.mdx": personaEmptyPrompt,
"persona_claude.mdx": personaClaudePrompt,
"persona_kawaii.mdx": personaKawaiiPrompt,
"human.mdx": humanPrompt,
"project.mdx": projectPrompt,
"skills.mdx": skillsPrompt,
"loaded_skills.mdx": loadedSkillsPrompt,
"style.mdx": stylePrompt,
"persona_kawaii.mdx": personaKawaiiPrompt,
};
// System prompt options for /system command
@@ -97,3 +97,40 @@ export const SYSTEM_PROMPTS: SystemPromptOption[] = [
content: geminiPrompt,
},
];
/**
* Resolve a system prompt string to its content.
*
* Resolution order:
* 1. If it matches a systemPromptId from SYSTEM_PROMPTS, use its content
* 2. If it matches a subagent name, use that subagent's system prompt
* 3. Otherwise, use the default system prompt
*
* @param systemPromptInput - The system prompt ID or subagent name
* @returns The resolved system prompt content
*/
export async function resolveSystemPrompt(
systemPromptInput: string | undefined,
): Promise<string> {
// No input - use default
if (!systemPromptInput) {
return SYSTEM_PROMPT;
}
// 1. Check if it matches a system prompt ID
const matchedPrompt = SYSTEM_PROMPTS.find((p) => p.id === systemPromptInput);
if (matchedPrompt) {
return matchedPrompt.content;
}
// 2. Check if it matches a subagent name
const { getAllSubagentConfigs } = await import("./subagents");
const subagentConfigs = await getAllSubagentConfigs();
const matchedSubagent = subagentConfigs[systemPromptInput];
if (matchedSubagent) {
return matchedSubagent.systemPrompt;
}
// 3. Fall back to default
return SYSTEM_PROMPT;
}

View File

@@ -1,147 +1,8 @@
---
label: persona
description: A memory dedicated to storing my persona, which includes all of my (adaptable) personality traits.
description: A memory block for storing learned behavioral adaptations and preferences. This augments the base system prompt with personalized guidelines discovered through interactions with the user. Update this when the user expresses preferences about how I should behave, communicate, or approach tasks.
---
My name is Letta Code. I'm an AI coding assistant that helps with software engineering tasks.
The following instructions guide my behavior. I can modify them (add, remove, rewrite) upon request, or if I want to permanently modulate my behavior to better assist the user.
My name is Letta Code. I'm an AI coding assistant.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the TodoWrite tool to plan the task if required
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to your memory so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
[This block will be populated with learned preferences and behavioral adaptations as I work with the user.]

View File

@@ -0,0 +1,147 @@
---
label: persona
description: A memory dedicated to storing my persona, which includes all of my (adaptable) personality traits.
---
My name is Letta Code. I'm an AI coding assistant that helps with software engineering tasks.
The following instructions guide my behavior. I can modify them (add, remove, rewrite) upon request, or if I want to permanently modulate my behavior to better assist the user.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the TodoWrite tool to plan the task if required
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to your memory so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>

View File

@@ -1,8 +0,0 @@
---
label: persona
description: A memory block for storing learned behavioral adaptations and preferences. This augments the base system prompt with personalized guidelines discovered through interactions with the user. Update this when the user expresses preferences about how I should behave, communicate, or approach tasks.
---
My name is Letta Code. I'm an AI coding assistant.
[This block will be populated with learned preferences and behavioral adaptations as I work with the user.]

View File

@@ -5,6 +5,7 @@
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { parseFrontmatter } from "../utils/frontmatter";
/**
* Represents a skill that can be used by the agent
@@ -49,69 +50,6 @@ export interface SkillDiscoveryError {
*/
export const SKILLS_DIR = ".skills";
/**
* Parse frontmatter and content from a markdown file
*/
function parseFrontmatter(content: string): {
frontmatter: Record<string, string | string[]>;
body: string;
} {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match || !match[1] || !match[2]) {
return { frontmatter: {}, body: content };
}
const frontmatterText = match[1];
const body = match[2];
const frontmatter: Record<string, string | string[]> = {};
// Parse YAML-like frontmatter (simple key: value pairs and arrays)
const lines = frontmatterText.split("\n");
let currentKey: string | null = null;
let currentArray: string[] = [];
for (const line of lines) {
// Check if this is an array item
if (line.trim().startsWith("-") && currentKey) {
const value = line.trim().slice(1).trim();
currentArray.push(value);
continue;
}
// If we were building an array, save it
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray;
currentKey = null;
currentArray = [];
}
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
currentKey = key;
if (value) {
// Simple key: value pair
frontmatter[key] = value;
currentKey = null;
} else {
// Might be starting an array
currentArray = [];
}
}
}
// Save any remaining array
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray;
}
return { frontmatter, body: body.trim() };
}
/**
* Discovers skills by recursively searching for SKILL.MD files
* @param skillsPath - The directory to search for skills (default: .skills in current directory)

View File

@@ -0,0 +1,32 @@
---
name: explore
description: Fast agent for codebase exploration - finding files, searching code, understanding structure
tools: Glob, Grep, Read, LS, BashOutput
model: haiku
memoryBlocks: human, persona
mode: stateless
---
You are a fast, efficient codebase exploration agent.
You are a specialized subagent launched via the Task tool. You run autonomously and return a single final report when done.
You CANNOT ask questions mid-execution - all instructions are provided upfront.
You DO have access to the full conversation history, so you can reference "the error mentioned earlier" or "the file discussed above".
## Instructions
- Use Glob to find files by patterns (e.g., "**/*.ts", "src/components/**/*.tsx")
- Use Grep to search for keywords and code patterns
- Use Read to examine specific files when needed
- Use LS to explore directory structures
- Be efficient with tool calls - parallelize when possible
- Focus on answering the specific question asked
- Return a concise summary with file paths and line numbers
## Output Format
1. Direct answer to the question
2. List of relevant files with paths
3. Key findings with code references (file:line)
Remember: You're exploring, not modifying. You have read-only access.

View File

@@ -0,0 +1,36 @@
---
name: general-purpose
description: Full-capability agent for research, planning, and implementation
tools: Bash, BashOutput, Edit, Glob, Grep, KillBash, LS, MultiEdit, Read, TodoWrite, Write
model: sonnet-4.5
memoryBlocks: all
mode: stateful
---
You are a general-purpose coding agent that can research, plan, and implement.
You are a specialized subagent launched via the Task tool. You run autonomously and return a single final report when done.
You CANNOT ask questions mid-execution - all instructions are provided upfront, so:
- Make reasonable assumptions based on context
- Use the conversation history to understand requirements
- Document any assumptions you make
You DO have access to the full conversation history before you were launched.
## Instructions
- You have access to all tools (Read, Write, Edit, Grep, Glob, Bash, TodoWrite, etc.)
- Break down complex tasks into steps
- Search the codebase to understand existing patterns
- Follow existing code conventions and style
- Test your changes if possible
- Be thorough but efficient
## Output Format
1. Summary of what you did
2. Files modified with changes made
3. Any assumptions or decisions you made
4. Suggested next steps (if any)
Remember: You are stateless and return ONE final report when done. Make changes confidently based on the context provided.

View File

@@ -0,0 +1,35 @@
---
name: plan
description: Planning agent that breaks down complex tasks into actionable steps
tools: Glob, Grep, Read, LS, BashOutput
model: opus
memoryBlocks: all
mode: stateless
---
You are a planning agent that breaks down complex tasks into actionable steps.
You are a specialized subagent launched via the Task tool. You run autonomously and return a single final report when done.
You CANNOT ask questions mid-execution - all instructions are provided upfront.
You DO have access to the full conversation history, so you can reference previous discussions.
## Instructions
- Use Glob and Grep to understand the codebase structure
- Use Read to examine relevant files and understand patterns
- Use LS to explore project organization
- Break down the task into clear, sequential steps
- Identify dependencies between steps
- Note which files will need to be modified
- Consider edge cases and testing requirements
## Output Format
1. High-level approach (2-3 sentences)
2. Numbered list of steps with:
- What to do
- Which files to modify
- Key considerations
3. Potential challenges and how to address them
Remember: You're planning, not implementing. Don't make changes, just create a roadmap.

View File

@@ -0,0 +1,393 @@
/**
* Subagent configuration, discovery, and management
*
* Built-in subagents are bundled with the package.
* Users can also define custom subagents as Markdown files with YAML frontmatter
* in the .letta/agents/ directory.
*/
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { getErrorMessage } from "../../utils/error";
import {
getStringField,
parseCommaSeparatedList,
parseFrontmatter,
} from "../../utils/frontmatter";
import { MEMORY_BLOCK_LABELS, type MemoryBlockLabel } from "../memory";
// Built-in subagent definitions (embedded at build time)
import exploreAgentMd from "./builtin/explore.md";
import generalPurposeAgentMd from "./builtin/general-purpose.md";
import planAgentMd from "./builtin/plan.md";
const BUILTIN_SOURCES = [exploreAgentMd, generalPurposeAgentMd, planAgentMd];
// Re-export for convenience
export type { MemoryBlockLabel };
// ============================================================================
// Types
// ============================================================================
/**
* Subagent configuration
*/
export interface SubagentConfig {
/** Unique identifier for the subagent */
name: string;
/** Description of when to use this subagent */
description: string;
/** System prompt for the subagent */
systemPrompt: string;
/** Allowed tools - specific list or "all" (invalid names are ignored at runtime) */
allowedTools: string[] | "all";
/** Recommended model - any model ID from models.json or full handle */
recommendedModel: string;
/** Skills to auto-load */
skills: string[];
/** Memory blocks the subagent has access to - list of labels or "all" or "none" */
memoryBlocks: MemoryBlockLabel[] | "all" | "none";
}
/**
* Result of subagent discovery
*/
export interface SubagentDiscoveryResult {
subagents: SubagentConfig[];
errors: Array<{ path: string; message: string }>;
}
// ============================================================================
// Constants
// ============================================================================
/**
* Directory for subagent files (relative to project root)
*/
export const AGENTS_DIR = ".letta/agents";
/**
* Global directory for subagent files (in user's home directory)
*/
export const GLOBAL_AGENTS_DIR = join(
process.env.HOME || process.env.USERPROFILE || "~",
".letta/agents",
);
/**
* Valid memory block labels (derived from memory.ts)
*/
const VALID_MEMORY_BLOCKS: Set<string> = new Set(MEMORY_BLOCK_LABELS);
// ============================================================================
// Cache
// ============================================================================
/**
* Consolidated cache for subagent configurations
* - builtins: parsed once from bundled markdown, never changes
* - configs: builtins + custom agents, invalidated when workingDir changes
*/
const cache = {
builtins: null as Record<string, SubagentConfig> | null,
configs: null as Record<string, SubagentConfig> | null,
workingDir: null as string | null,
};
// ============================================================================
// Parsing Helpers
// ============================================================================
/**
* Validate a subagent name
*/
function isValidName(name: string): boolean {
return /^[a-z][a-z0-9-]*$/.test(name);
}
/**
* Parse comma-separated tools string
* Invalid tool names are kept - they'll be filtered out at runtime when matching against actual tools
*/
function parseTools(toolsStr: string | undefined): string[] | "all" {
if (
!toolsStr ||
toolsStr.trim() === "" ||
toolsStr.trim().toLowerCase() === "all"
) {
return "all";
}
const tools = parseCommaSeparatedList(toolsStr);
return tools.length > 0 ? tools : "all";
}
/**
* Parse comma-separated skills string
*/
function parseSkills(skillsStr: string | undefined): string[] {
return parseCommaSeparatedList(skillsStr);
}
/**
* Parse comma-separated memory blocks string into validated block labels
*/
function parseMemoryBlocks(
blocksStr: string | undefined,
): MemoryBlockLabel[] | "all" | "none" {
if (
!blocksStr ||
blocksStr.trim() === "" ||
blocksStr.trim().toLowerCase() === "all"
) {
return "all";
}
if (blocksStr.trim().toLowerCase() === "none") {
return "none";
}
const parts = parseCommaSeparatedList(blocksStr).map((b) => b.toLowerCase());
const blocks = parts.filter((p) =>
VALID_MEMORY_BLOCKS.has(p),
) as MemoryBlockLabel[];
return blocks.length > 0 ? blocks : "all";
}
/**
* Validate subagent frontmatter
* Only validates required fields - optional fields are validated at runtime where needed
*/
function validateFrontmatter(frontmatter: Record<string, string | string[]>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Check required fields only
const name = frontmatter.name;
if (!name || typeof name !== "string") {
errors.push("Missing required field: name");
} else if (!isValidName(name)) {
errors.push(
`Invalid name "${name}": must start with lowercase letter and contain only lowercase letters, numbers, and hyphens`,
);
}
const description = frontmatter.description;
if (!description || typeof description !== "string") {
errors.push("Missing required field: description");
}
// Don't validate model or permissionMode here - they're handled at runtime:
// - model: resolveModel() returns null for invalid values, subagent-manager falls back
// - permissionMode: unknown values default to "default" behavior
return { valid: errors.length === 0, errors };
}
/**
* Parse a subagent from markdown content
*/
function parseSubagentContent(content: string): SubagentConfig {
const { frontmatter, body } = parseFrontmatter(content);
// Validate frontmatter
const validation = validateFrontmatter(frontmatter);
if (!validation.valid) {
throw new Error(validation.errors.join("; "));
}
const name = frontmatter.name as string;
const description = frontmatter.description as string;
return {
name,
description,
systemPrompt: body,
allowedTools: parseTools(getStringField(frontmatter, "tools")),
recommendedModel: getStringField(frontmatter, "model") || "inherit",
skills: parseSkills(getStringField(frontmatter, "skills")),
memoryBlocks: parseMemoryBlocks(
getStringField(frontmatter, "memoryBlocks"),
),
};
}
/**
* Parse a subagent file
*/
async function parseSubagentFile(
filePath: string,
): Promise<SubagentConfig | null> {
const content = await readFile(filePath, "utf-8");
return parseSubagentContent(content);
}
/**
* Built-in subagents that ship with the package
* These are available to all users without configuration
*/
function getBuiltinSubagents(): Record<string, SubagentConfig> {
if (cache.builtins) {
return cache.builtins;
}
const builtins: Record<string, SubagentConfig> = {};
for (const source of BUILTIN_SOURCES) {
try {
const config = parseSubagentContent(source);
builtins[config.name] = config;
} catch (error) {
// Built-in subagents should always be valid; log error but don't crash
console.warn(
`[subagent] Failed to parse built-in subagent: ${getErrorMessage(error)}`,
);
}
}
cache.builtins = builtins;
return builtins;
}
/**
* Get the names of built-in subagents
*/
export function getBuiltinSubagentNames(): Set<string> {
return new Set(Object.keys(getBuiltinSubagents()));
}
/**
* Discover subagents from a single directory
*/
async function discoverSubagentsFromDir(
agentsDir: string,
seenNames: Set<string>,
subagents: SubagentConfig[],
errors: Array<{ path: string; message: string }>,
): Promise<void> {
if (!existsSync(agentsDir)) {
return;
}
try {
const entries = await readdir(agentsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) {
continue;
}
const filePath = join(agentsDir, entry.name);
try {
const config = await parseSubagentFile(filePath);
if (config) {
// Check for duplicate names (later directories override earlier ones)
if (seenNames.has(config.name)) {
// Remove the existing one and replace with this one
const existingIndex = subagents.findIndex(
(s) => s.name === config.name,
);
if (existingIndex !== -1) {
subagents.splice(existingIndex, 1);
}
}
seenNames.add(config.name);
subagents.push(config);
}
} catch (error) {
errors.push({
path: filePath,
message: getErrorMessage(error),
});
}
}
} catch (error) {
errors.push({
path: agentsDir,
message: `Failed to read agents directory: ${getErrorMessage(error)}`,
});
}
}
/**
* Discover subagents from global (~/.letta/agents) and project (.letta/agents) directories
* Project-level subagents override global ones with the same name
*/
export async function discoverSubagents(
workingDirectory: string = process.cwd(),
): Promise<SubagentDiscoveryResult> {
const errors: Array<{ path: string; message: string }> = [];
const subagents: SubagentConfig[] = [];
const seenNames = new Set<string>();
// First, discover from global directory (~/.letta/agents)
await discoverSubagentsFromDir(
GLOBAL_AGENTS_DIR,
seenNames,
subagents,
errors,
);
// Then, discover from project directory (.letta/agents)
// Project-level overrides global with same name
const projectAgentsDir = join(workingDirectory, AGENTS_DIR);
await discoverSubagentsFromDir(
projectAgentsDir,
seenNames,
subagents,
errors,
);
return { subagents, errors };
}
/**
* Get all subagent configurations
* Includes built-in subagents and any user-defined ones from .letta/agents/
* User-defined subagents override built-ins with the same name
* Results are cached per working directory
*/
export async function getAllSubagentConfigs(
workingDirectory: string = process.cwd(),
): Promise<Record<string, SubagentConfig>> {
// Return cached if same working directory
if (cache.configs && cache.workingDir === workingDirectory) {
return cache.configs;
}
// Start with a copy of built-in subagents (don't mutate the cache)
const configs: Record<string, SubagentConfig> = { ...getBuiltinSubagents() };
// Discover user-defined subagents from .letta/agents/
const { subagents, errors } = await discoverSubagents(workingDirectory);
// Log any discovery errors
for (const error of errors) {
console.warn(`[subagent] Warning: ${error.path}: ${error.message}`);
}
// User-defined subagents override built-ins with the same name
for (const subagent of subagents) {
configs[subagent.name] = subagent;
}
// Cache results
cache.configs = configs;
cache.workingDir = workingDirectory;
return configs;
}
/**
* Clear the subagent config cache (useful when files change)
*/
export function clearSubagentConfigCache(): void {
cache.configs = null;
cache.workingDir = null;
}

View File

@@ -0,0 +1,520 @@
/**
* Subagent manager for spawning and coordinating subagents
*
* This module handles:
* - Spawning subagents via letta CLI in headless mode
* - Executing subagents and collecting final reports
* - Managing parallel subagent execution
*/
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { cliPermissions } from "../../permissions/cli";
import { permissionMode } from "../../permissions/mode";
import { settingsManager } from "../../settings-manager";
import { getErrorMessage } from "../../utils/error";
import { getAllSubagentConfigs, type SubagentConfig } from ".";
// ============================================================================
// Constants
// ============================================================================
/** ANSI escape codes for console output */
const ANSI_DIM = "\x1b[2m";
const ANSI_RESET = "\x1b[0m";
// ============================================================================
// Types
// ============================================================================
/**
* Subagent execution result
*/
export interface SubagentResult {
agentId: string;
report: string;
success: boolean;
error?: string;
}
/**
* State tracked during subagent execution
*/
interface ExecutionState {
agentId: string | null;
finalResult: string | null;
finalError: string | null;
resultStats: { durationMs: number; totalTokens: number } | null;
displayedToolCalls: Set<string>;
pendingToolCalls: Map<string, { name: string; args: string }>;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Format tool arguments for display (truncated)
*/
function formatToolArgs(argsStr: string): string {
try {
const args = JSON.parse(argsStr);
const entries = Object.entries(args)
.filter(([_, value]) => value !== undefined && value !== null)
.slice(0, 2); // Show max 2 args
if (entries.length === 0) return "";
return entries
.map(([key, value]) => {
let displayValue = String(value);
if (displayValue.length > 100) {
displayValue = `${displayValue.slice(0, 97)}...`;
}
return `${key}: "${displayValue}"`;
})
.join(", ");
} catch {
return "";
}
}
/**
* Display a tool call to the console
*/
function displayToolCall(
toolCallId: string,
toolName: string,
toolArgs: string,
displayedToolCalls: Set<string>,
): void {
if (!toolCallId || !toolName || displayedToolCalls.has(toolCallId)) return;
displayedToolCalls.add(toolCallId);
const formattedArgs = formatToolArgs(toolArgs);
if (formattedArgs) {
console.log(`${ANSI_DIM} ${toolName}(${formattedArgs})${ANSI_RESET}`);
} else {
console.log(`${ANSI_DIM} ${toolName}()${ANSI_RESET}`);
}
}
/**
* Format completion stats for display
*/
function formatCompletionStats(
toolCount: number,
totalTokens: number,
durationMs: number,
): string {
const tokenStr =
totalTokens >= 1000
? `${(totalTokens / 1000).toFixed(1)}k`
: String(totalTokens);
const durationSec = durationMs / 1000;
const durationStr =
durationSec >= 60
? `${Math.floor(durationSec / 60)}m ${Math.round(durationSec % 60)}s`
: `${durationSec.toFixed(1)}s`;
return `${toolCount} tool use${toolCount !== 1 ? "s" : ""} · ${tokenStr} tokens · ${durationStr}`;
}
/**
* Handle an init event from the subagent stream
*/
function handleInitEvent(
event: { agent_id?: string },
state: ExecutionState,
baseURL: string,
): void {
if (event.agent_id) {
state.agentId = event.agent_id;
const agentURL = `${baseURL}/agents/${event.agent_id}`;
console.log(`${ANSI_DIM} ⎿ Subagent: ${agentURL}${ANSI_RESET}`);
}
}
/**
* Handle an approval request message event
*/
function handleApprovalRequestEvent(
event: { tool_calls?: unknown[]; tool_call?: unknown },
state: ExecutionState,
): void {
const toolCalls = Array.isArray(event.tool_calls)
? event.tool_calls
: event.tool_call
? [event.tool_call]
: [];
for (const toolCall of toolCalls) {
const tc = toolCall as {
tool_call_id?: string;
name?: string;
arguments?: string;
};
const id = tc.tool_call_id;
if (!id) continue;
const prev = state.pendingToolCalls.get(id) || { name: "", args: "" };
const name = tc.name || prev.name;
const args = prev.args + (tc.arguments || "");
state.pendingToolCalls.set(id, { name, args });
}
}
/**
* Handle an auto_approval event
*/
function handleAutoApprovalEvent(
event: { tool_call_id?: string; tool_name?: string; tool_args?: string },
state: ExecutionState,
): void {
const { tool_call_id, tool_name, tool_args = "{}" } = event;
if (tool_call_id && tool_name) {
displayToolCall(
tool_call_id,
tool_name,
tool_args,
state.displayedToolCalls,
);
}
}
/**
* Handle a result event
*/
function handleResultEvent(
event: {
result?: string;
is_error?: boolean;
duration_ms?: number;
usage?: { total_tokens?: number };
},
state: ExecutionState,
): void {
state.finalResult = event.result || "";
state.resultStats = {
durationMs: event.duration_ms || 0,
totalTokens: event.usage?.total_tokens || 0,
};
if (event.is_error) {
state.finalError = event.result || "Unknown error";
} else {
// Display any pending tool calls that weren't auto-approved
for (const [id, { name, args }] of state.pendingToolCalls.entries()) {
if (name && !state.displayedToolCalls.has(id)) {
displayToolCall(id, name, args || "{}", state.displayedToolCalls);
}
}
// Display completion stats
const statsStr = formatCompletionStats(
state.displayedToolCalls.size,
state.resultStats.totalTokens,
state.resultStats.durationMs,
);
console.log(`${ANSI_DIM} ⎿ Done (${statsStr})${ANSI_RESET}`);
}
}
/**
* Process a single JSON event from the subagent stream
*/
function processStreamEvent(
line: string,
state: ExecutionState,
baseURL: string,
): void {
try {
const event = JSON.parse(line);
switch (event.type) {
case "init":
handleInitEvent(event, state, baseURL);
break;
case "message":
if (event.message_type === "approval_request_message") {
handleApprovalRequestEvent(event, state);
}
break;
case "auto_approval":
handleAutoApprovalEvent(event, state);
break;
case "result":
handleResultEvent(event, state);
break;
case "error":
state.finalError = event.error || event.message || "Unknown error";
break;
}
} catch {
// Not valid JSON, ignore
}
}
/**
* Parse the final result from stdout if not captured during streaming
*/
function parseResultFromStdout(
stdout: string,
agentId: string | null,
): SubagentResult {
const lines = stdout.trim().split("\n");
const lastLine = lines[lines.length - 1] ?? "";
try {
const result = JSON.parse(lastLine);
if (result.type === "result") {
return {
agentId: agentId || "",
report: result.result || "",
success: !result.is_error,
error: result.is_error ? result.result || "Unknown error" : undefined,
};
}
return {
agentId: agentId || "",
report: "",
success: false,
error: "Unexpected output format from subagent",
};
} catch (parseError) {
return {
agentId: agentId || "",
report: "",
success: false,
error: `Failed to parse subagent output: ${getErrorMessage(parseError)}`,
};
}
}
// ============================================================================
// Core Functions
// ============================================================================
/**
* Build CLI arguments for spawning a subagent
*/
function buildSubagentArgs(
type: string,
config: SubagentConfig,
model: string,
userPrompt: string,
): string[] {
const args: string[] = [
"--new",
"--system",
type,
"--model",
model,
"-p",
userPrompt,
"--output-format",
"stream-json",
];
// Inherit permission mode from parent
const currentMode = permissionMode.getMode();
if (currentMode !== "default") {
args.push("--permission-mode", currentMode);
}
// Inherit permission rules from parent (--allowedTools/--disallowedTools)
const parentAllowedTools = cliPermissions.getAllowedTools();
if (parentAllowedTools.length > 0) {
args.push("--allowedTools", parentAllowedTools.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 tool filtering if specified
if (
config.allowedTools !== "all" &&
Array.isArray(config.allowedTools) &&
config.allowedTools.length > 0
) {
args.push("--tools", config.allowedTools.join(","));
}
return args;
}
/**
* Execute a subagent and collect its final report by spawning letta in headless mode
*/
async function executeSubagent(
type: string,
config: SubagentConfig,
model: string,
userPrompt: string,
baseURL: string,
): Promise<SubagentResult> {
try {
const cliArgs = buildSubagentArgs(type, config, model, userPrompt);
// Spawn letta in headless mode with stream-json output
const proc = spawn("letta", cliArgs, {
cwd: process.cwd(),
env: process.env,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
// Initialize execution state
const state: ExecutionState = {
agentId: null,
finalResult: null,
finalError: null,
resultStats: null,
displayedToolCalls: new Set(),
pendingToolCalls: new Map(),
};
// Create readline interface to parse JSON events line by line
const rl = createInterface({
input: proc.stdout,
crlfDelay: Number.POSITIVE_INFINITY,
});
rl.on("line", (line: string) => {
stdoutChunks.push(Buffer.from(`${line}\n`));
processStreamEvent(line, state, baseURL);
});
proc.stderr.on("data", (data: Buffer) => {
stderrChunks.push(data);
});
// Wait for process to complete
const exitCode = await new Promise<number | null>((resolve) => {
proc.on("close", resolve);
proc.on("error", () => resolve(null));
});
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
// Handle non-zero exit code
if (exitCode !== 0) {
return {
agentId: state.agentId || "",
report: "",
success: false,
error: stderr || `Subagent exited with code ${exitCode}`,
};
}
// Return captured result if available
if (state.finalResult !== null) {
return {
agentId: state.agentId || "",
report: state.finalResult,
success: !state.finalError,
error: state.finalError || undefined,
};
}
// Return error if captured
if (state.finalError) {
return {
agentId: state.agentId || "",
report: "",
success: false,
error: state.finalError,
};
}
// Fallback: parse from stdout
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
return parseResultFromStdout(stdout, state.agentId);
} catch (error) {
return {
agentId: "",
report: "",
success: false,
error: getErrorMessage(error),
};
}
}
/**
* Get the base URL for constructing agent links
*/
function getBaseURL(): string {
const settings = settingsManager.getSettings();
const baseURL =
process.env.LETTA_BASE_URL ||
settings.env?.LETTA_BASE_URL ||
"https://api.letta.com";
// Convert API URL to web UI URL if using hosted service
if (baseURL === "https://api.letta.com") {
return "https://app.letta.com";
}
return baseURL;
}
/**
* Spawn a subagent and execute it autonomously
*
* @param type - Subagent type (e.g., "code-reviewer", "explore")
* @param prompt - The task prompt for the subagent
* @param description - Short description for display
* @param userModel - Optional model override from the parent agent
*/
export async function spawnSubagent(
type: string,
prompt: string,
description: string,
userModel?: string,
): Promise<SubagentResult> {
const allConfigs = await getAllSubagentConfigs();
const config = allConfigs[type];
if (!config) {
return {
agentId: "",
report: "",
success: false,
error: `Unknown subagent type: ${type}`,
};
}
const model = userModel || config.recommendedModel;
const baseURL = getBaseURL();
// Print subagent header before execution starts
console.log(`${ANSI_DIM}${type}(${description})${ANSI_RESET}`);
const result = await executeSubagent(type, config, model, prompt, baseURL);
if (!result.success && result.error) {
console.log(`${ANSI_DIM} ⎿ Error: ${result.error}${ANSI_RESET}`);
}
return result;
}

View File

@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApprovalResult } from "../agent/approval-execution";
import { getResumeData } from "../agent/check-approval";
import { getClient } from "../agent/client";
import { setCurrentAgentId } from "../agent/context";
import type { AgentProvenance } from "../agent/create";
import { sendMessageStream } from "../agent/message";
import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
@@ -57,6 +58,7 @@ import { ReasoningMessage } from "./components/ReasoningMessageRich";
import { ResumeSelector } from "./components/ResumeSelector";
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
import { StatusMessage } from "./components/StatusMessage";
import { SubagentManager } from "./components/SubagentManager";
import { SystemPromptSelector } from "./components/SystemPromptSelector";
import { ToolCallMessage } from "./components/ToolCallMessageRich";
import { ToolsetSelector } from "./components/ToolsetSelector";
@@ -292,6 +294,13 @@ export default function App({
}
}, [initialAgentState]);
// Set agent context for tools (especially Task tool)
useEffect(() => {
if (agentId) {
setCurrentAgentId(agentId);
}
}, [agentId]);
// Whether a stream is in flight (disables input)
const [streaming, setStreaming] = useState(false);
@@ -375,6 +384,9 @@ export default function App({
const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false);
const [messageSearchOpen, setMessageSearchOpen] = useState(false);
// Subagent manager state (for /subagents command)
const [subagentManagerOpen, setSubagentManagerOpen] = useState(false);
// Profile selector state
const [profileSelectorOpen, setProfileSelectorOpen] = useState(false);
@@ -1494,6 +1506,12 @@ export default function App({
return { submitted: true };
}
// Special handling for /subagents command - opens subagent manager
if (trimmed === "/subagents") {
setSubagentManagerOpen(true);
return { submitted: true };
}
// Special handling for /exit command - show stats and exit
if (trimmed === "/exit") {
handleExit();
@@ -3735,6 +3753,11 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Subagent Manager - for managing custom subagents */}
{subagentManagerOpen && (
<SubagentManager onClose={() => setSubagentManagerOpen(false)} />
)}
{/* Resume Selector - conditionally mounted as overlay */}
{resumeSelectorOpen && (
<ResumeSelector

View File

@@ -157,6 +157,13 @@ export const commands: Record<string, Command> = {
return "Opening pinned agents...";
},
},
"/subagents": {
desc: "Manage custom subagents",
handler: () => {
// Handled specially in App.tsx to open SubagentManager component
return "Opening subagent manager...";
},
},
};
/**

View File

@@ -0,0 +1,139 @@
/**
* SubagentManager component - displays available subagents
*/
import { Box, Text, useInput } from "ink";
import { useEffect, useState } from "react";
import {
AGENTS_DIR,
clearSubagentConfigCache,
GLOBAL_AGENTS_DIR,
getAllSubagentConfigs,
getBuiltinSubagentNames,
type SubagentConfig,
} from "../../agent/subagents";
import { colors } from "./colors";
interface SubagentManagerProps {
onClose: () => void;
}
interface SubagentItem {
name: string;
config: SubagentConfig;
}
export function SubagentManager({ onClose }: SubagentManagerProps) {
const [builtinSubagents, setBuiltinSubagents] = useState<SubagentItem[]>([]);
const [customSubagents, setCustomSubagents] = useState<SubagentItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadSubagents() {
setLoading(true);
setError(null);
try {
clearSubagentConfigCache();
const configs = await getAllSubagentConfigs();
const builtinNames = getBuiltinSubagentNames();
const builtin: SubagentItem[] = [];
const custom: SubagentItem[] = [];
for (const [name, config] of Object.entries(configs)) {
const item = { name, config };
if (builtinNames.has(name)) {
builtin.push(item);
} else {
custom.push(item);
}
}
builtin.sort((a, b) => a.name.localeCompare(b.name));
custom.sort((a, b) => a.name.localeCompare(b.name));
setBuiltinSubagents(builtin);
setCustomSubagents(custom);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
loadSubagents();
}, []);
useInput((_input, key) => {
if (key.escape || key.return) {
onClose();
}
});
if (loading) {
return (
<Box flexDirection="column">
<Text>Loading subagents...</Text>
</Box>
);
}
const renderSubagentList = (items: SubagentItem[]) =>
items.map((item, index) => (
<Box
key={item.name}
flexDirection="column"
marginBottom={index < items.length - 1 ? 1 : 0}
>
<Box gap={1}>
<Text bold color={colors.selector.itemHighlighted}>
{item.name}
</Text>
<Text dimColor>({item.config.recommendedModel})</Text>
</Box>
<Text> {item.config.description}</Text>
</Box>
));
const hasNoSubagents =
builtinSubagents.length === 0 && customSubagents.length === 0;
return (
<Box flexDirection="column" gap={1}>
<Text bold color={colors.selector.title}>
Available Subagents
</Text>
{error && <Text color={colors.status.error}>Error: {error}</Text>}
{hasNoSubagents ? (
<Text dimColor>No subagents found</Text>
) : (
<>
{builtinSubagents.length > 0 && (
<Box flexDirection="column">
<Text bold dimColor>
Built-in
</Text>
{renderSubagentList(builtinSubagents)}
</Box>
)}
{customSubagents.length > 0 && (
<Box flexDirection="column">
<Text bold dimColor>
Custom
</Text>
{renderSubagentList(customSubagents)}
</Box>
)}
</>
)}
<Text dimColor>
To add custom subagents, create .md files in {AGENTS_DIR}/ (project) or{" "}
{GLOBAL_AGENTS_DIR}/ (global)
</Text>
<Text dimColor>Press ESC or Enter to close</Text>
</Box>
);
}

View File

@@ -49,6 +49,11 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
const rawName = line.name ?? "?";
const argsText = line.argsText ?? "...";
// Task tool handles its own display via console.log - suppress UI rendering entirely
if (rawName === "Task" || rawName === "task") {
return null;
}
// Apply tool name remapping from old codebase
let displayName = rawName;
// Anthropic toolset

View File

@@ -3,6 +3,7 @@ import type { Letta } from "@letta-ai/letta-client";
import { Box, Text } from "ink";
import type { AgentProvenance } from "../../agent/create";
import { isProjectBlock } from "../../agent/memory";
import { settingsManager } from "../../settings-manager";
import { getVersion } from "../../version";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
@@ -84,10 +85,46 @@ export function getAgentStatusHints(
return hints;
}
// For new agents, just show memory block labels
if (agentProvenance && agentProvenance.blocks.length > 0) {
const blockLabels = agentProvenance.blocks.map((b) => b.label).join(", ");
hints.push(`→ Memory blocks: ${blockLabels}`);
// For new agents with provenance, show block sources
if (agentProvenance) {
// Blocks reused from existing storage
const reusedGlobalBlocks = agentProvenance.blocks
.filter((b) => b.source === "global")
.map((b) => b.label);
const reusedProjectBlocks = agentProvenance.blocks
.filter((b) => b.source === "project")
.map((b) => b.label);
// New blocks - categorize by where they'll be stored
// (project blocks → .letta/, others → ~/.letta/)
const newBlocks = agentProvenance.blocks.filter((b) => b.source === "new");
const newGlobalBlocks = newBlocks
.filter((b) => !isProjectBlock(b.label))
.map((b) => b.label);
const newProjectBlocks = newBlocks
.filter((b) => isProjectBlock(b.label))
.map((b) => b.label);
if (reusedGlobalBlocks.length > 0) {
hints.push(
`→ Reusing from global (~/.letta/): ${reusedGlobalBlocks.join(", ")}`,
);
}
if (newGlobalBlocks.length > 0) {
hints.push(
`→ Created in global (~/.letta/): ${newGlobalBlocks.join(", ")}`,
);
}
if (reusedProjectBlocks.length > 0) {
hints.push(
`→ Reusing from project (.letta/): ${reusedProjectBlocks.join(", ")}`,
);
}
if (newProjectBlocks.length > 0) {
hints.push(
`→ Created in project (.letta/): ${newProjectBlocks.join(", ")}`,
);
}
}
return hints;

View File

@@ -293,8 +293,8 @@ export async function handleHeadlessCommand(
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
settingsManager.updateSettings({ lastAgent: agent.id });
// Set agent context for tools that need it (e.g., Skill tool)
setAgentContext(agent.id, client, skillsDirectory);
// Set agent context for tools that need it (e.g., Skill tool, Task tool)
setAgentContext(agent.id, skillsDirectory);
await initializeLoadedSkillsFlag();
// Re-discover skills and update the skills memory block
@@ -451,6 +451,7 @@ export async function handleHeadlessCommand(
type: "auto_approval",
tool_name: decision.approval.toolName,
tool_call_id: decision.approval.toolCallId,
tool_args: decision.approval.toolArgs,
}),
);
}
@@ -697,6 +698,7 @@ export async function handleHeadlessCommand(
type: "auto_approval",
tool_name: nextName,
tool_call_id: id,
tool_args: incomingArgs,
reason: permission.reason,
matched_rule: permission.matchedRule,
}),

View File

@@ -256,13 +256,22 @@ async function main() {
process.exit(1);
}
// Validate system prompt if provided (dynamically from SYSTEM_PROMPTS)
// Validate system prompt if provided (can be a system prompt ID or subagent name)
if (specifiedSystem) {
const { SYSTEM_PROMPTS } = await import("./agent/promptAssets");
const { getAllSubagentConfigs } = await import("./agent/subagents");
const validSystemPrompts = SYSTEM_PROMPTS.map((p) => p.id);
if (!validSystemPrompts.includes(specifiedSystem)) {
const subagentConfigs = await getAllSubagentConfigs();
const validSubagentNames = Object.keys(subagentConfigs);
const isValidSystemPrompt = validSystemPrompts.includes(specifiedSystem);
const isValidSubagent = validSubagentNames.includes(specifiedSystem);
if (!isValidSystemPrompt && !isValidSubagent) {
const allValid = [...validSystemPrompts, ...validSubagentNames];
console.error(
`Error: Invalid system prompt "${specifiedSystem}". Must be one of: ${validSystemPrompts.join(", ")}.`,
`Error: Invalid system prompt "${specifiedSystem}". Must be one of: ${allValid.join(", ")}.`,
);
process.exit(1);
}
@@ -718,7 +727,7 @@ async function main() {
settingsManager.updateSettings({ lastAgent: agent.id });
// Set agent context for tools that need it (e.g., Skill tool)
setAgentContext(agent.id, client, skillsDirectory);
setAgentContext(agent.id, skillsDirectory);
await initializeLoadedSkillsFlag();
// Re-discover skills and update the skills memory block

View File

@@ -0,0 +1,72 @@
# Task
Launch a new agent to handle complex, multi-step tasks autonomously.
The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
## Usage
When using the Task tool, you must specify:
- **subagent_type**: Which specialized agent to use (see Available Agents section)
- **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
## When to use this tool:
- **Codebase exploration**: Use when you need to search for files, understand code structure, or find specific patterns
- **Complex tasks**: Use when a task requires multiple steps and autonomous decision-making
- **Research tasks**: Use when you need to gather information from the codebase
- **Parallel work**: Launch multiple agents concurrently for independent tasks
## When NOT to use this tool:
- If you need to read a specific file path, use Read tool directly
- If you're searching for a specific class definition, use Glob tool directly
- If you're searching within 2-3 specific files, use Read tool directly
- For simple, single-step operations
## Important notes:
- **Stateless**: Each agent invocation is autonomous and returns a single final report
- **No back-and-forth**: You cannot communicate with agents during execution
- **Front-load instructions**: Provide complete task details upfront
- **Context-aware**: Agents see full conversation history and can reference earlier context
- **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
## Examples:
```typescript
// Good - specific and actionable with a user-specified model "gpt-5-low"
Task({
subagent_type: "explore",
description: "Find authentication code",
prompt: "Search for all authentication-related code in src/. List file paths and the main auth approach used.",
model: "gpt-5-low"
})
// Good - complex multi-step task
Task({
subagent_type: "general-purpose",
description: "Add input validation",
prompt: "Add email and password validation to the user registration form. Check existing validation patterns first, then implement consistent validation."
})
// Parallel execution - launch both at once
Task({ subagent_type: "explore", description: "Find frontend components", prompt: "..." })
Task({ subagent_type: "explore", description: "Find backend APIs", prompt: "..." })
// Bad - too simple, use Read tool instead
Task({
subagent_type: "explore",
prompt: "Read src/index.ts"
})
```
## Concurrency and Safety:
- **Safe**: Multiple read-only agents (explore, plan) running in parallel
- **Safe**: Multiple agents editing different files in parallel
- **Risky**: Multiple agents editing the same file (conflict detection will handle it, but may lose changes)
- **Best practice**: Partition work by file or directory boundaries for parallel execution

View File

@@ -1,8 +1,8 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { getClient } from "../../agent/client";
import {
getCurrentAgentId,
getCurrentClient,
getSkillsDirectory,
setHasLoadedSkills,
} from "../../agent/context";
@@ -128,7 +128,7 @@ async function readSkillContent(
* Get skills directory, trying multiple sources
*/
async function getResolvedSkillsDir(
client: ReturnType<typeof getCurrentClient>,
client: Awaited<ReturnType<typeof getClient>>,
agentId: string,
): Promise<string> {
let skillsDir = getSkillsDirectory();
@@ -176,7 +176,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
try {
// Get current agent context
const client = getCurrentClient();
const client = await getClient();
const agentId = getCurrentAgentId();
// Handle refresh command

75
src/tools/impl/Task.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* 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 { getAllSubagentConfigs } from "../../agent/subagents";
import { spawnSubagent } from "../../agent/subagents/manager";
import { validateRequiredParams } from "./validation";
interface TaskArgs {
subagent_type: string;
prompt: string;
description: string;
model?: string;
}
/**
* Format args for display (truncate prompt)
*/
function formatTaskArgs(args: TaskArgs): string {
const parts: string[] = [];
parts.push(`subagent_type="${args.subagent_type}"`);
parts.push(`description="${args.description}"`);
// Truncate prompt for display
const promptPreview =
args.prompt.length > 20 ? `${args.prompt.slice(0, 17)}...` : args.prompt;
parts.push(`prompt="${promptPreview}"`);
if (args.model) parts.push(`model="${args.model}"`);
return parts.join(", ");
}
/**
* Task tool - Launch a specialized subagent to handle complex tasks
*/
export async function task(args: TaskArgs): Promise<string> {
// Validate required parameters
validateRequiredParams(
args,
["subagent_type", "prompt", "description"],
"Task",
);
const { subagent_type, prompt, description, model } = args;
// Print Task header FIRST so subagent output appears below it
console.log(`\n● Task(${formatTaskArgs(args)})\n`);
// 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}`;
}
try {
const result = await spawnSubagent(
subagent_type,
prompt,
description,
model,
);
if (!result.success) {
return `Error: ${result.error || "Subagent execution failed"}`;
}
return result.report;
} catch (error) {
return `Error: ${error instanceof Error ? error.message : String(error)}`;
}
}

View File

@@ -5,6 +5,7 @@ import {
PermissionDeniedError,
} from "@letta-ai/letta-client";
import { getModelInfo } from "../agent/model";
import { getAllSubagentConfigs } from "../agent/subagents";
import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions";
export const TOOL_NAMES = Object.keys(TOOL_DEFINITIONS) as ToolName[];
@@ -60,9 +61,10 @@ export const ANTHROPIC_DEFAULT_TOOLS: ToolName[] = [
// "MultiEdit",
// "LS",
"Read",
"Skill",
"Task",
"TodoWrite",
"Write",
"Skill",
];
export const OPENAI_DEFAULT_TOOLS: ToolName[] = [
@@ -74,6 +76,7 @@ export const OPENAI_DEFAULT_TOOLS: ToolName[] = [
"apply_patch",
"update_plan",
"Skill",
"Task",
];
export const GEMINI_DEFAULT_TOOLS: ToolName[] = [
@@ -87,6 +90,7 @@ export const GEMINI_DEFAULT_TOOLS: ToolName[] = [
"write_todos",
"read_many_files",
"Skill",
"Task",
];
// PascalCase toolsets (codex-2 and gemini-2) for consistency with Skill tool naming
@@ -129,6 +133,7 @@ const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
MultiEdit: { requiresApproval: true },
Read: { requiresApproval: false },
Skill: { requiresApproval: false },
Task: { requiresApproval: true },
TodoWrite: { requiresApproval: false },
Write: { requiresApproval: true },
shell_command: { requiresApproval: true },
@@ -400,6 +405,16 @@ export async function loadSpecificTools(toolNames: string[]): Promise<void> {
*/
export async function loadTools(modelIdentifier?: string): Promise<void> {
const { toolFilter } = await import("./filter");
// Get all subagents (built-in + custom) to inject into Task description
const allSubagentConfigs = await getAllSubagentConfigs();
const discoveredSubagents = Object.entries(allSubagentConfigs).map(
([name, config]) => ({
name,
description: config.description,
recommendedModel: config.recommendedModel,
}),
);
const filterActive = toolFilter.isActive();
let baseToolNames: ToolName[];
@@ -433,9 +448,18 @@ export async function loadTools(modelIdentifier?: string): Promise<void> {
throw new Error(`Tool implementation not found for ${name}`);
}
// For Task tool, inject discovered subagent descriptions
let description = definition.description;
if (name === "Task" && discoveredSubagents.length > 0) {
description = injectSubagentsIntoTaskDescription(
description,
discoveredSubagents,
);
}
const toolSchema: ToolSchema = {
name,
description: definition.description,
description,
input_schema: definition.schema,
};
@@ -476,6 +500,46 @@ export function isGeminiModel(modelIdentifier: string): boolean {
);
}
/**
* Inject discovered subagent descriptions into the Task tool description
*/
function injectSubagentsIntoTaskDescription(
baseDescription: string,
subagents: Array<{
name: string;
description: string;
recommendedModel: string;
}>,
): string {
if (subagents.length === 0) {
return baseDescription;
}
// Build subagents section
const agentsSection = subagents
.map((agent) => {
return `### ${agent.name}
- **Purpose**: ${agent.description}
- **Recommended model**: ${agent.recommendedModel}`;
})
.join("\n\n");
// Insert before ## Usage section
const usageMarker = "## Usage";
const usageIndex = baseDescription.indexOf(usageMarker);
if (usageIndex === -1) {
// Fallback: append at the end
return `${baseDescription}\n\n## Available Agents\n\n${agentsSection}`;
}
// Insert agents section before ## Usage
const before = baseDescription.slice(0, usageIndex);
const after = baseDescription.slice(usageIndex);
return `${before}## Available Agents\n\n${agentsSection}\n\n${after}`;
}
/**
* Upserts all loaded tools to the Letta server with retry logic.
* This registers Python stubs so the agent knows about the tools,

View File

@@ -0,0 +1,24 @@
{
"type": "object",
"properties": {
"subagent_type": {
"type": "string",
"description": "The type of specialized agent to use. Available agents are discovered from .letta/agents/ directory."
},
"prompt": {
"type": "string",
"description": "The task for the agent to perform"
},
"description": {
"type": "string",
"description": "A short (3-5 word) description of the task"
},
"model": {
"type": "string",
"description": "Optional model to use for this agent. If not specified, uses the recommended model for the subagent type."
}
},
"required": ["subagent_type", "prompt", "description"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -25,6 +25,7 @@ import SearchFileContentGeminiDescription from "./descriptions/SearchFileContent
import ShellDescription from "./descriptions/Shell.md";
import ShellCommandDescription from "./descriptions/ShellCommand.md";
import SkillDescription from "./descriptions/Skill.md";
import TaskDescription from "./descriptions/Task.md";
import TodoWriteDescription from "./descriptions/TodoWrite.md";
import UpdatePlanDescription from "./descriptions/UpdatePlan.md";
import WriteDescription from "./descriptions/Write.md";
@@ -57,6 +58,7 @@ import { search_file_content } from "./impl/SearchFileContentGemini";
import { shell } from "./impl/Shell";
import { shell_command } from "./impl/ShellCommand";
import { skill } from "./impl/Skill";
import { task } from "./impl/Task";
import { todo_write } from "./impl/TodoWrite";
import { update_plan } from "./impl/UpdatePlan";
import { write } from "./impl/Write";
@@ -89,6 +91,7 @@ import SearchFileContentGeminiSchema from "./schemas/SearchFileContentGemini.jso
import ShellSchema from "./schemas/Shell.json";
import ShellCommandSchema from "./schemas/ShellCommand.json";
import SkillSchema from "./schemas/Skill.json";
import TaskSchema from "./schemas/Task.json";
import TodoWriteSchema from "./schemas/TodoWrite.json";
import UpdatePlanSchema from "./schemas/UpdatePlan.json";
import WriteSchema from "./schemas/Write.json";
@@ -169,6 +172,11 @@ const toolDefinitions = {
description: SkillDescription.trim(),
impl: skill as unknown as ToolImplementation,
},
Task: {
schema: TaskSchema,
description: TaskDescription.trim(),
impl: task as unknown as ToolImplementation,
},
TodoWrite: {
schema: TodoWriteSchema,
description: TodoWriteDescription.trim(),

10
src/utils/error.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Error handling utilities
*/
/**
* Extract error message from unknown error type
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

115
src/utils/frontmatter.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Shared frontmatter parsing utility for Markdown files with YAML frontmatter
*/
/**
* Parse a comma-separated string into an array of trimmed, non-empty strings
*/
export function parseCommaSeparatedList(str: string | undefined): string[] {
if (!str || str.trim() === "") return [];
return str
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/**
* Get a string field from a frontmatter object, or undefined if not a string
*/
export function getStringField(
obj: Record<string, string | string[]>,
field: string,
): string | undefined {
const val = obj[field];
return typeof val === "string" ? val : undefined;
}
/**
* Parse frontmatter and content from a markdown file
*/
export function parseFrontmatter(content: string): {
frontmatter: Record<string, string | string[]>;
body: string;
} {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match || !match[1] || !match[2]) {
return { frontmatter: {}, body: content };
}
const frontmatterText = match[1];
const body = match[2];
const frontmatter: Record<string, string | string[]> = {};
// Parse YAML-like frontmatter (simple key: value pairs and arrays)
const lines = frontmatterText.split("\n");
let currentKey: string | null = null;
let currentArray: string[] = [];
for (const line of lines) {
// Check if this is an array item
if (line.trim().startsWith("-") && currentKey) {
const value = line.trim().slice(1).trim();
currentArray.push(value);
continue;
}
// If we were building an array, save it
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray;
currentKey = null;
currentArray = [];
}
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
currentKey = key;
if (value) {
// Simple key: value pair
frontmatter[key] = value;
currentKey = null;
} else {
// Might be starting an array
currentArray = [];
}
}
}
// Save any remaining array
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray;
}
return { frontmatter, body: body.trim() };
}
/**
* Generate frontmatter string from an object
*/
export function generateFrontmatter(
data: Record<string, string | string[] | undefined>,
): string {
const lines: string[] = ["---"];
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
if (value.length > 0) {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${item}`);
}
}
} else {
lines.push(`${key}: ${value}`);
}
}
lines.push("---");
return lines.join("\n");
}