feat: Model based toolset switching (#111)

Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Kevin Lin
2025-11-23 19:49:39 -08:00
committed by GitHub
parent 9ceae2af58
commit cd6b29e686
34 changed files with 1124 additions and 68 deletions

View File

@@ -4,7 +4,7 @@
"": {
"name": "@letta-ai/letta-code",
"dependencies": {
"@letta-ai/letta-client": "1.0.0-alpha.15",
"@letta-ai/letta-client": "^1.1.2",
"ink-link": "^5.0.0",
"open": "^10.2.0",
},
@@ -35,7 +35,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.0.0-alpha.15", "", {}, "sha512-5OpXmloDnboA0nYC9xJIJuIWzAaVS06uDr9YLO6hR29zblwgeHPpaopWJFyg+FR0Cg7SSyPgEb3xzjGdRd6Eqg=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.1.2", "", {}, "sha512-p8YYdDoM4s0KY5eo7zByr3q3iIuEAZrFrwa9FgjfIMB6sRno33bjIY8sazCb3lhhQZ/2SUkus0ngZ2ImxAmMig=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],

View File

@@ -22,7 +22,7 @@
"access": "public"
},
"dependencies": {
"@letta-ai/letta-client": "1.0.0-alpha.15",
"@letta-ai/letta-client": "^1.1.2",
"ink-link": "^5.0.0",
"open": "^10.2.0"
},

View File

@@ -1,8 +1,7 @@
// src/agent/approval-execution.ts
// Shared logic for executing approval batches (used by both interactive and headless modes)
import type {
ApprovalCreate,
ApprovalReturn,
ToolReturn,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools";
@@ -14,7 +13,7 @@ export type ApprovalDecision =
| { type: "deny"; approval: ApprovalRequest; reason: string };
// Align result type with the SDK's expected union for approvals payloads
export type ApprovalResult = ToolReturn | ApprovalCreate.ApprovalReturn;
export type ApprovalResult = ToolReturn | ApprovalReturn;
/**
* Execute a batch of approval decisions and format results for the backend.

View File

@@ -3,7 +3,7 @@
import type Letta from "@letta-ai/letta-client";
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import type { LettaMessageUnion } from "@letta-ai/letta-client/resources/agents/messages";
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
import type { ApprovalRequest } from "../cli/helpers/stream";
// Number of recent messages to backfill when resuming a session
@@ -12,7 +12,7 @@ const MESSAGE_HISTORY_LIMIT = 15;
export interface ResumeData {
pendingApproval: ApprovalRequest | null; // Deprecated: use pendingApprovals
pendingApprovals: ApprovalRequest[];
messageHistory: LettaMessageUnion[];
messageHistory: Message[];
}
/**
@@ -100,7 +100,7 @@ export async function getResumeData(
if (messageToCheck.message_type === "approval_request_message") {
// Cast to access tool_calls with proper typing
const approvalMsg = messageToCheck as LettaMessageUnion & {
const approvalMsg = messageToCheck as Message & {
tool_calls?: Array<{
tool_call_id?: string;
name?: string;
@@ -123,12 +123,17 @@ export async function getResumeData(
// Extract ALL tool calls for parallel approval support
// Include ALL tool_call_ids, even those with incomplete name/arguments
// Incomplete entries will be denied at the business logic layer
type ToolCallEntry = {
tool_call_id?: string;
name?: string;
arguments?: string;
};
pendingApprovals = toolCalls
.filter(
(tc): tc is typeof tc & { tool_call_id: string } =>
(tc: ToolCallEntry): tc is ToolCallEntry & { tool_call_id: string } =>
!!tc && !!tc.tool_call_id,
)
.map((tc) => ({
.map((tc: ToolCallEntry & { tool_call_id: string }) => ({
toolCallId: tc.tool_call_id,
toolName: tc.name || "",
toolArgs: tc.arguments || "",

View File

@@ -250,7 +250,7 @@ export async function createAgent(
const groupAgent = await client.agents.retrieve(groupAgentId);
if (groupAgent.agent_type === "sleeptime_agent") {
// Update the persona block on the SLEEPTIME agent, not the primary agent
await client.agents.blocks.modify("memory_persona", {
await client.agents.blocks.update("memory_persona", {
agent_id: groupAgentId,
value: SLEEPTIME_MEMORY_PERSONA,
description:

View File

@@ -2,7 +2,7 @@
// Utilities for modifying agent configuration
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
import { getToolNames } from "../tools/manager";
import { getAllLettaToolNames, getToolNames } from "../tools/manager";
import { getClient } from "./client";
/**
@@ -19,35 +19,42 @@ import { getClient } from "./client";
*/
export async function updateAgentLLMConfig(
agentId: string,
_modelHandle: string,
modelHandle: string,
updateArgs?: Record<string, unknown>,
preserveParallelToolCalls?: boolean,
): Promise<LlmConfig> {
const client = await getClient();
// Get current agent to preserve parallel_tool_calls if requested
// Step 1: change model (preserve parallel_tool_calls if requested)
const currentAgent = await client.agents.retrieve(agentId);
const originalParallelToolCalls = preserveParallelToolCalls
? (currentAgent.llm_config?.parallel_tool_calls ?? undefined)
const currentParallel = preserveParallelToolCalls
? currentAgent.llm_config?.parallel_tool_calls
: undefined;
// Strategy: Do everything in ONE modify call via llm_config
// This avoids the backend resetting parallel_tool_calls when we update the model
const updatedLlmConfig = {
...currentAgent.llm_config,
...updateArgs,
// Explicitly preserve parallel_tool_calls
...(originalParallelToolCalls !== undefined && {
parallel_tool_calls: originalParallelToolCalls,
}),
} as LlmConfig;
await client.agents.modify(agentId, {
llm_config: updatedLlmConfig,
parallel_tool_calls: originalParallelToolCalls,
await client.agents.update(agentId, {
model: modelHandle,
parallel_tool_calls: currentParallel,
});
// Retrieve and return final state
// Step 2: if there are llm_config overrides, apply them using fresh state
if (updateArgs && Object.keys(updateArgs).length > 0) {
const refreshed = await client.agents.retrieve(agentId);
const refreshedConfig = (refreshed.llm_config || {}) as LlmConfig;
const mergedLlmConfig: LlmConfig = {
...refreshedConfig,
...(updateArgs as Record<string, unknown>),
...(currentParallel !== undefined && {
parallel_tool_calls: currentParallel,
}),
} as LlmConfig;
await client.agents.update(agentId, {
llm_config: mergedLlmConfig,
parallel_tool_calls: currentParallel,
});
}
const finalAgent = await client.agents.retrieve(agentId);
return finalAgent.llm_config;
}
@@ -75,7 +82,9 @@ export async function linkToolsToAgent(agentId: string): Promise<LinkResult> {
const client = await getClient();
// Get ALL agent tools from agent state
const agent = await client.agents.retrieve(agentId);
const agent = await client.agents.retrieve(agentId, {
include: ["agent.tools"],
});
const currentTools = agent.tools || [];
const currentToolIds = currentTools
.map((t) => t.id)
@@ -105,8 +114,8 @@ export async function linkToolsToAgent(agentId: string): Promise<LinkResult> {
// Look up tool IDs from global tool list
const toolsToAddIds: string[] = [];
for (const toolName of toolsToAdd) {
const tools = await client.tools.list({ name: toolName });
const tool = tools[0];
const toolsResponse = await client.tools.list({ name: toolName });
const tool = toolsResponse.items[0];
if (tool?.id) {
toolsToAddIds.push(tool.id);
}
@@ -126,7 +135,7 @@ export async function linkToolsToAgent(agentId: string): Promise<LinkResult> {
})),
];
await client.agents.modify(agentId, {
await client.agents.update(agentId, {
tool_ids: newToolIds,
tool_rules: newToolRules,
});
@@ -157,9 +166,11 @@ export async function unlinkToolsFromAgent(
const client = await getClient();
// Get ALL agent tools from agent state (not tools.list which may be incomplete)
const agent = await client.agents.retrieve(agentId);
const agent = await client.agents.retrieve(agentId, {
include: ["agent.tools"],
});
const allTools = agent.tools || [];
const lettaCodeToolNames = new Set(getToolNames());
const lettaCodeToolNames = new Set(getAllLettaToolNames());
// Filter out Letta Code tools, keep everything else
const remainingTools = allTools.filter(
@@ -180,7 +191,7 @@ export async function unlinkToolsFromAgent(
!lettaCodeToolNames.has(rule.tool_name),
);
await client.agents.modify(agentId, {
await client.agents.update(agentId, {
tool_ids: remainingToolIds,
tool_rules: remainingToolRules,
});

View File

@@ -7,7 +7,7 @@ import type {
} from "@letta-ai/letta-client/resources/agents/agents";
import type {
ApprovalCreate,
LettaMessageUnion,
Message,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
import { Box, Static } from "ink";
@@ -131,7 +131,7 @@ export default function App({
continueSession?: boolean;
startupApproval?: ApprovalRequest | null; // Deprecated: use startupApprovals
startupApprovals?: ApprovalRequest[];
messageHistory?: LettaMessageUnion[];
messageHistory?: Message[];
tokenStreaming?: boolean;
}) {
// Track current agent (can change when swapping)
@@ -1107,7 +1107,7 @@ export default function App({
try {
const client = await getClient();
await client.agents.modify(agentId, { name: newName });
await client.agents.update(agentId, { name: newName });
setAgentName(newName);
buffersRef.current.byId.set(cmdId, {
@@ -1719,12 +1719,27 @@ export default function App({
);
setLlmConfig(updatedConfig);
// Update the same command with final result
// After switching models, reload tools for the selected provider and relink
const { switchToolsetForModel } = await import("../tools/toolset");
const toolsetName = await switchToolsetForModel(
selectedModel.handle ?? "",
agentId,
);
// Update the same command with final result (include toolset info)
const autoToolsetLine = toolsetName
? `Automatically switched toolset to ${toolsetName}. Use /toolset to change back if desired.`
: null;
const outputLines = [
`Switched to ${selectedModel.label}`,
...(autoToolsetLine ? [autoToolsetLine] : []),
].join("\n");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: `/model ${modelId}`,
output: `Switched to ${selectedModel.label}`,
output: outputLines,
phase: "finished",
success: true,
});

View File

@@ -1,7 +1,7 @@
import type {
LettaAssistantMessageContentUnion,
LettaMessageUnion,
LettaUserMessageContentUnion,
Message,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { Buffers } from "./accumulator";
@@ -53,10 +53,7 @@ function renderUserContentParts(
return out;
}
export function backfillBuffers(
buffers: Buffers,
history: LettaMessageUnion[],
): void {
export function backfillBuffers(buffers: Buffers, history: Message[]): void {
// Clear buffers to ensure idempotency (in case this is called multiple times)
buffers.order = [];
buffers.byId.clear();

View File

@@ -37,6 +37,7 @@ OPTIONS
--skills <path> Custom path to skills directory (default: .skills in current directory)
--sleeptime Enable sleeptime memory management (only for new agents)
BEHAVIOR
By default, letta auto-resumes the last agent used in the current directory
(stored in .letta/settings.local.json).
@@ -269,8 +270,8 @@ async function main() {
}
if (isHeadless) {
// For headless mode, load tools synchronously
await loadTools();
// For headless mode, load tools synchronously (respecting model when provided)
await loadTools(specifiedModel);
const client = await getClient();
await upsertToolsToServer(client);
@@ -318,7 +319,7 @@ async function main() {
useEffect(() => {
async function init() {
setLoadingState("assembling");
await loadTools();
await loadTools(model);
setLoadingState("upserting");
const client = await getClient();

View File

@@ -41,6 +41,7 @@ export function analyzeApprovalContext(
switch (toolName) {
case "Read":
case "read_file":
return analyzeReadApproval(resolveFilePath(), workingDirectory);
case "Write":
@@ -51,6 +52,8 @@ export function analyzeApprovalContext(
return analyzeEditApproval(resolveFilePath(), workingDirectory);
case "Bash":
case "shell":
case "shell_command":
return analyzeBashApproval(
typeof toolArgs.command === "string" ? toolArgs.command : "",
workingDirectory,
@@ -63,6 +66,7 @@ export function analyzeApprovalContext(
case "Glob":
case "Grep":
case "grep_files":
return analyzeSearchApproval(
toolName,
typeof toolArgs.path === "string" ? toolArgs.path : workingDirectory,

View File

@@ -217,6 +217,7 @@ function isWithinAllowedDirectories(
function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string {
switch (toolName) {
case "Read":
case "read_file":
case "Write":
case "Edit":
case "Glob":
@@ -232,6 +233,16 @@ function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string {
typeof toolArgs.command === "string" ? toolArgs.command : "";
return `Bash(${command})`;
}
case "shell":
case "shell_command": {
const command =
typeof toolArgs.command === "string"
? toolArgs.command
: Array.isArray(toolArgs.command)
? toolArgs.command.join(" ")
: "";
return `Bash(${command})`;
}
default:
// Other tools: just the tool name
@@ -249,12 +260,26 @@ function matchesPattern(
workingDirectory: string,
): boolean {
// File tools use glob matching
if (["Read", "Write", "Edit", "Glob", "Grep"].includes(toolName)) {
if (
[
"Read",
"read_file",
"Write",
"Edit",
"Glob",
"Grep",
"grep_files",
].includes(toolName)
) {
return matchesFilePattern(query, pattern, workingDirectory);
}
// Bash uses prefix matching
if (toolName === "Bash") {
if (
toolName === "Bash" ||
toolName === "shell" ||
toolName === "shell_command"
) {
return matchesBashPattern(query, pattern);
}

View File

@@ -59,7 +59,9 @@ describeOrSkip("Link/Unlink Tools", () => {
expect(result.addedCount).toBeGreaterThan(0);
// Verify tools were attached
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const toolNames = agent.tools?.map((t) => t.name) || [];
const lettaCodeTools = getToolNames();
@@ -76,7 +78,9 @@ describeOrSkip("Link/Unlink Tools", () => {
await linkToolsToAgent(testAgentId);
// Verify approval rules were added
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const approvalRules = agent.tool_rules?.filter(
(rule) => rule.type === "requires_approval",
);
@@ -115,7 +119,9 @@ describeOrSkip("Link/Unlink Tools", () => {
expect(result.removedCount).toBeGreaterThan(0);
// Verify tools were removed
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const toolNames = agent.tools?.map((t) => t.name) || [];
const lettaCodeTools = getToolNames();
@@ -132,7 +138,9 @@ describeOrSkip("Link/Unlink Tools", () => {
await unlinkToolsFromAgent(testAgentId);
// Verify approval rules were removed
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const approvalRules = agent.tool_rules?.filter(
(rule) => rule.type === "requires_approval",
);
@@ -150,8 +158,8 @@ describeOrSkip("Link/Unlink Tools", () => {
await linkToolsToAgent(testAgentId);
// Attach memory tool
const memoryTools = await client.tools.list({ name: "memory" });
const memoryTool = memoryTools[0];
const memoryToolsResponse = await client.tools.list({ name: "memory" });
const memoryTool = memoryToolsResponse.items[0];
if (memoryTool?.id) {
await client.agents.tools.attach(memoryTool.id, {
agent_id: testAgentId,
@@ -162,7 +170,9 @@ describeOrSkip("Link/Unlink Tools", () => {
await unlinkToolsFromAgent(testAgentId);
// Verify memory tool is still there
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const toolNames = agent.tools?.map((t) => t.name) || [];
expect(toolNames).toContain("memory");
@@ -179,7 +189,9 @@ describeOrSkip("Link/Unlink Tools", () => {
await linkToolsToAgent(testAgentId);
// Add a continue_loop rule manually
const agent = await client.agents.retrieve(testAgentId);
const agent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const newToolRules = [
...(agent.tool_rules || []),
{
@@ -189,13 +201,15 @@ describeOrSkip("Link/Unlink Tools", () => {
},
];
await client.agents.modify(testAgentId, { tool_rules: newToolRules });
await client.agents.update(testAgentId, { tool_rules: newToolRules });
// Unlink Letta Code tools
await unlinkToolsFromAgent(testAgentId);
// Verify continue_loop rule is still there
const updatedAgent = await client.agents.retrieve(testAgentId);
const updatedAgent = await client.agents.retrieve(testAgentId, {
include: ["agent.tools"],
});
const continueLoopRules = updatedAgent.tool_rules?.filter(
(r) => r.type === "continue_loop" && r.tool_name === "memory",
);

View File

@@ -0,0 +1,23 @@
# apply_patch
Applies a patch to the local filesystem using the Codex/Letta ApplyPatch format.
- **input**: Required patch string using the `*** Begin Patch` / `*** End Patch` envelope and per-file sections:
- `*** Add File: path` followed by one or more `+` lines with the file contents.
- `*** Update File: path` followed by one or more `@@` hunks where each line starts with a space (` `), minus (`-`), or plus (`+`), representing context, removed, and added lines respectively.
- `*** Delete File: path` to delete an existing file.
- Paths are interpreted relative to the current working directory.
- The tool validates that each hunk's old content appears in the target file and fails if it cannot be applied cleanly.

View File

@@ -0,0 +1,21 @@
# grep_files
Finds files whose contents match a regular expression pattern, similar to Codex's `grep_files` tool.
- **pattern**: Required regular expression pattern to search for.
- **include**: Optional glob that limits which files are searched (for example `*.rs` or `*.{ts,tsx}`).
- **path**: Optional directory or file path to search (defaults to the current working directory).
- **limit**: Accepted for compatibility but currently ignored; output may be truncated for very large result sets.

View File

@@ -0,0 +1,19 @@
# list_dir
Lists entries in a local directory, compatible with the Codex `list_dir` tool.
- **dir_path**: Absolute path to the directory to list.
- **offset / limit / depth**: Accepted for compatibility but currently ignored; the underlying implementation returns a tree-style listing of the directory.

View File

@@ -0,0 +1,21 @@
# read_file
Reads a local file with 1-indexed line numbers, compatible with the Codex `read_file` tool.
- **file_path**: Absolute path to the file to read.
- **offset**: Optional starting line number (1-based) for the slice.
- **limit**: Optional maximum number of lines to return.
- **mode / indentation**: Accepted for compatibility with Codex but currently treated as slice-only; indentation mode is not yet implemented.

View File

@@ -0,0 +1,21 @@
# shell
Runs a shell command represented as an array of arguments and returns its output.
- **command**: Required array of strings to execute, typically starting with the shell (for example `["bash", "-lc", "npm test"]`).
- **workdir**: Optional working directory to run the command in; prefer using this instead of `cd`.
- **timeout_ms**: Optional timeout in milliseconds (defaults to 120000ms / 2 minutes).
- **with_escalated_permissions / justification**: Accepted for compatibility with Codex; currently treated as hints only and do not bypass local sandboxing.

View File

@@ -0,0 +1,21 @@
# shell_command
Runs a shell script string in the user's default shell and returns its output.
- **command**: Required shell script to execute (for example `ls -la` or `pytest tests`).
- **workdir**: Optional working directory to run the command in; prefer using this instead of `cd`.
- **timeout_ms**: Optional timeout in milliseconds (defaults to 120000ms / 2 minutes).
- **with_escalated_permissions / justification**: Accepted for compatibility with Codex; currently treated as hints only and do not bypass local sandboxing.

View File

@@ -0,0 +1,269 @@
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { validateRequiredParams } from "./validation.js";
interface ApplyPatchArgs {
input: string;
}
interface ApplyPatchResult {
message: string;
}
type FileOperation =
| {
kind: "add";
path: string;
contentLines: string[];
}
| {
kind: "update";
fromPath: string;
toPath?: string;
hunks: Hunk[];
}
| {
kind: "delete";
path: string;
};
interface Hunk {
lines: string[]; // raw hunk lines (excluding the @@ header)
}
/**
* Simple ApplyPatch implementation compatible with the Letta/Codex apply_patch tool format.
*
* Supports:
* - *** Add File: path
* - *** Update File: path
* - optional *** Move to: new_path
* - one or more @@ hunks with space/-/+ lines
* - *** Delete File: path
*/
export async function apply_patch(
args: ApplyPatchArgs,
): Promise<ApplyPatchResult> {
validateRequiredParams(args, ["input"], "apply_patch");
const { input } = args;
const lines = input.split(/\r?\n/);
if (lines[0]?.trim() !== "*** Begin Patch") {
throw new Error('Patch must start with "*** Begin Patch"');
}
const endIndex = lines.lastIndexOf("*** End Patch");
if (endIndex === -1) {
throw new Error('Patch must end with "*** End Patch"');
}
const ops: FileOperation[] = [];
let i = 1;
while (i < endIndex) {
const line = lines[i]?.trim();
if (!line) {
i += 1;
continue;
}
if (line.startsWith("*** Add File:")) {
const filePath = line.replace("*** Add File:", "").trim();
i += 1;
const contentLines: string[] = [];
while (i < endIndex) {
const raw = lines[i];
if (raw === undefined || raw.startsWith("*** ")) break;
if (raw.startsWith("+")) {
contentLines.push(raw.slice(1));
}
i += 1;
}
ops.push({ kind: "add", path: filePath, contentLines });
continue;
}
if (line.startsWith("*** Update File:")) {
const fromPath = line.replace("*** Update File:", "").trim();
i += 1;
let toPath: string | undefined;
if (i < endIndex) {
const moveLine = lines[i];
if (moveLine?.startsWith("*** Move to:")) {
toPath = moveLine.replace("*** Move to:", "").trim();
i += 1;
}
}
const hunks: Hunk[] = [];
while (i < endIndex) {
const hLine = lines[i];
if (hLine === undefined || hLine.startsWith("*** ")) break;
if (hLine.startsWith("@@")) {
// Start of a new hunk
i += 1;
const hunkLines: string[] = [];
while (i < endIndex) {
const l = lines[i];
if (l === undefined || l.startsWith("@@") || l.startsWith("*** ")) {
break;
}
if (
l.startsWith(" ") ||
l.startsWith("+") ||
l.startsWith("-") ||
l === ""
) {
hunkLines.push(l);
}
i += 1;
}
hunks.push({ lines: hunkLines });
continue;
}
// Skip stray lines until next header/hunk
i += 1;
}
if (hunks.length === 0) {
throw new Error(`Update for file ${fromPath} has no hunks`);
}
ops.push({ kind: "update", fromPath, toPath, hunks });
continue;
}
if (line.startsWith("*** Delete File:")) {
const filePath = line.replace("*** Delete File:", "").trim();
ops.push({ kind: "delete", path: filePath });
i += 1;
continue;
}
// Unknown directive; skip
i += 1;
}
const cwd = process.cwd();
const pendingWrites = new Map<string, string>();
// Helper to get current content (including prior ops in this patch)
const loadFile = async (relativePath: string): Promise<string> => {
const abs = path.resolve(cwd, relativePath);
const cached = pendingWrites.get(abs);
if (cached !== undefined) return cached;
try {
const buf = await fs.readFile(abs, "utf8");
return buf;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
throw new Error(`File not found for update: ${relativePath}`);
}
throw err;
}
};
const saveFile = (relativePath: string, content: string) => {
const abs = path.resolve(cwd, relativePath);
pendingWrites.set(abs, content);
};
// Apply all operations in memory first
for (const op of ops) {
if (op.kind === "add") {
const abs = path.resolve(cwd, op.path);
const content = op.contentLines.join("\n");
pendingWrites.set(abs, content);
} else if (op.kind === "update") {
const currentPath = op.fromPath;
let content = await loadFile(currentPath);
for (const hunk of op.hunks) {
const { oldChunk, newChunk } = buildOldNewChunks(hunk.lines);
if (!oldChunk) {
continue;
}
const idx = content.indexOf(oldChunk);
if (idx === -1) {
throw new Error(
`Failed to apply hunk to ${currentPath}: context not found`,
);
}
content =
content.slice(0, idx) +
newChunk +
content.slice(idx + oldChunk.length);
}
const targetPath = op.toPath ?? op.fromPath;
saveFile(targetPath, content);
// If file was renamed, also clear the old path so we don't write both
if (op.toPath && op.toPath !== op.fromPath) {
const oldAbs = path.resolve(cwd, op.fromPath);
if (pendingWrites.has(oldAbs)) {
pendingWrites.delete(oldAbs);
}
}
}
}
// Apply deletes on disk
for (const op of ops) {
if (op.kind === "delete") {
const abs = path.resolve(cwd, op.path);
try {
await fs.unlink(abs);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
throw err;
}
}
}
}
// Flush writes to disk
for (const [absPath, content] of pendingWrites.entries()) {
const dir = path.dirname(absPath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(absPath, content, "utf8");
}
return {
message: "Patch applied successfully",
};
}
function buildOldNewChunks(lines: string[]): {
oldChunk: string;
newChunk: string;
} {
const oldParts: string[] = [];
const newParts: string[] = [];
for (const raw of lines) {
if (raw === "") {
oldParts.push("\n");
newParts.push("\n");
continue;
}
const prefix = raw[0];
const text = raw.slice(1);
if (prefix === " ") {
oldParts.push(`${text}\n`);
newParts.push(`${text}\n`);
} else if (prefix === "-") {
oldParts.push(`${text}\n`);
} else if (prefix === "+") {
newParts.push(`${text}\n`);
}
}
return {
oldChunk: oldParts.join(""),
newChunk: newParts.join(""),
};
}

View File

@@ -0,0 +1,32 @@
import { type GrepArgs, grep } from "./Grep.js";
import { validateRequiredParams } from "./validation.js";
interface GrepFilesArgs {
pattern: string;
include?: string;
path?: string;
limit?: number;
}
type GrepFilesResult = Awaited<ReturnType<typeof grep>>;
/**
* Codex-style grep_files tool.
* Uses the existing Grep implementation and returns a list of files with matches.
*/
export async function grep_files(
args: GrepFilesArgs,
): Promise<GrepFilesResult> {
validateRequiredParams(args, ["pattern"], "grep_files");
const { pattern, include, path } = args;
const grepArgs: GrepArgs = {
pattern,
path,
glob: include,
output_mode: "files_with_matches",
};
return grep(grepArgs);
}

View File

@@ -0,0 +1,26 @@
import { ls } from "./LS.js";
import { validateRequiredParams } from "./validation.js";
interface ListDirCodexArgs {
dir_path: string;
offset?: number;
limit?: number;
depth?: number;
}
type ListDirCodexResult = Awaited<ReturnType<typeof ls>>;
/**
* Codex-style list_dir tool.
* Delegates to the existing LS implementation; offset/limit/depth are accepted but currently ignored.
*/
export async function list_dir(
args: ListDirCodexArgs,
): Promise<ListDirCodexResult> {
validateRequiredParams(args, ["dir_path"], "list_dir");
const { dir_path } = args;
// LS handles path resolution and formatting.
return ls({ path: dir_path, ignore: [] });
}

View File

@@ -0,0 +1,42 @@
import { read } from "./Read.js";
import { validateRequiredParams } from "./validation.js";
interface IndentationOptions {
anchor_line?: number;
max_levels?: number;
include_siblings?: boolean;
include_header?: boolean;
max_lines?: number;
}
interface ReadFileCodexArgs {
file_path: string;
offset?: number;
limit?: number;
mode?: "slice" | "indentation" | string;
indentation?: IndentationOptions;
}
interface ReadFileCodexResult {
content: string;
}
/**
* Codex-style read_file tool.
* Currently supports slice-style reading; indentation mode is ignored but accepted.
*/
export async function read_file(
args: ReadFileCodexArgs,
): Promise<ReadFileCodexResult> {
validateRequiredParams(args, ["file_path"], "read_file");
const { file_path, offset, limit } = args;
const result = await read({
file_path,
offset,
limit,
});
return { content: result.content };
}

72
src/tools/impl/Shell.ts Normal file
View File

@@ -0,0 +1,72 @@
import { bash } from "./Bash.js";
import { validateRequiredParams } from "./validation.js";
interface ShellArgs {
command: string[];
workdir?: string;
timeout_ms?: number;
with_escalated_permissions?: boolean;
justification?: string;
}
interface ShellResult {
output: string;
stdout: string[];
stderr: string[];
}
/**
* Codex-style shell tool.
* Runs an array of shell arguments, typically ["bash", "-lc", "..."].
*/
export async function shell(args: ShellArgs): Promise<ShellResult> {
validateRequiredParams(args, ["command"], "shell");
const { command, workdir, timeout_ms, justification: description } = args;
if (!Array.isArray(command) || command.length === 0) {
throw new Error("command must be a non-empty array of strings");
}
const commandString = command.join(" ");
const previousUserCwd = process.env.USER_CWD;
if (workdir) {
process.env.USER_CWD = workdir;
}
try {
const result = await bash({
command: commandString,
timeout: timeout_ms ?? 120000,
description,
run_in_background: false,
});
const text = (result.content ?? [])
.map((item) =>
"text" in item && typeof item.text === "string" ? item.text : "",
)
.filter(Boolean)
.join("\n");
const stdout = text ? text.split("\n") : [];
const stderr =
result.status === "error"
? ["Command reported an error. See output for details."]
: [];
return {
output: text,
stdout,
stderr,
};
} finally {
if (workdir) {
if (previousUserCwd === undefined) {
delete process.env.USER_CWD;
} else {
process.env.USER_CWD = previousUserCwd;
}
}
}
}

View File

@@ -0,0 +1,70 @@
import { bash } from "./Bash.js";
import { validateRequiredParams } from "./validation.js";
interface ShellCommandArgs {
command: string;
workdir?: string;
timeout_ms?: number;
with_escalated_permissions?: boolean;
justification?: string;
}
interface ShellCommandResult {
output: string;
stdout: string[];
stderr: string[];
}
/**
* Codex-style shell_command tool.
* Runs a shell script string in the user's default shell.
*/
export async function shell_command(
args: ShellCommandArgs,
): Promise<ShellCommandResult> {
validateRequiredParams(args, ["command"], "shell_command");
const { command, workdir, timeout_ms, justification: description } = args;
// Reuse Bash implementation for execution, but honor the requested workdir
const previousUserCwd = process.env.USER_CWD;
if (workdir) {
process.env.USER_CWD = workdir;
}
try {
const result = await bash({
command,
timeout: timeout_ms ?? 120000,
description,
run_in_background: false,
});
const text = (result.content ?? [])
.map((item) =>
"text" in item && typeof item.text === "string" ? item.text : "",
)
.filter(Boolean)
.join("\n");
const stdout = text ? text.split("\n") : [];
const stderr =
result.status === "error"
? ["Command reported an error. See output for details."]
: [];
return {
output: text,
stdout,
stderr,
};
} finally {
if (workdir) {
if (previousUserCwd === undefined) {
delete process.env.USER_CWD;
} else {
process.env.USER_CWD = previousUserCwd;
}
}
}
}

View File

@@ -3,10 +3,35 @@ import {
AuthenticationError,
PermissionDeniedError,
} from "@letta-ai/letta-client";
import { getModelInfo } from "../agent/model";
import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions";
export const TOOL_NAMES = Object.keys(TOOL_DEFINITIONS) as ToolName[];
const ANTHROPIC_DEFAULT_TOOLS: ToolName[] = [
"Bash",
"BashOutput",
"Edit",
"ExitPlanMode",
"Glob",
"Grep",
"KillBash",
"LS",
"MultiEdit",
"Read",
"TodoWrite",
"Write",
];
const OPENAI_DEFAULT_TOOLS: ToolName[] = [
"shell_command",
"shell",
"read_file",
"list_dir",
"grep_files",
"apply_patch",
];
// Tool permissions configuration
const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
Bash: { requiresApproval: true },
@@ -21,6 +46,12 @@ const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
Read: { requiresApproval: false },
TodoWrite: { requiresApproval: false },
Write: { requiresApproval: true },
shell_command: { requiresApproval: true },
shell: { requiresApproval: true },
read_file: { requiresApproval: false },
list_dir: { requiresApproval: false },
grep_files: { requiresApproval: false },
apply_patch: { requiresApproval: true },
};
interface JsonSchema {
@@ -186,10 +217,21 @@ export async function analyzeToolApproval(
*
* @returns Promise that resolves when all tools are loaded
*/
export async function loadTools(): Promise<void> {
export async function loadTools(modelIdentifier?: string): Promise<void> {
const { toolFilter } = await import("./filter");
const filterActive = toolFilter.isActive();
for (const name of TOOL_NAMES) {
let baseToolNames: ToolName[];
if (!filterActive && modelIdentifier && isOpenAIModel(modelIdentifier)) {
baseToolNames = OPENAI_DEFAULT_TOOLS;
} else if (!filterActive) {
baseToolNames = ANTHROPIC_DEFAULT_TOOLS;
} else {
// When user explicitly sets --tools, respect that and allow any tool name
baseToolNames = TOOL_NAMES;
}
for (const name of baseToolNames) {
if (!toolFilter.isEnabled(name)) {
continue;
}
@@ -224,6 +266,15 @@ export async function loadTools(): Promise<void> {
}
}
export function isOpenAIModel(modelIdentifier: string): boolean {
const info = getModelInfo(modelIdentifier);
if (info?.handle && typeof info.handle === "string") {
return info.handle.startsWith("openai/");
}
// Fallback: treat raw handle-style identifiers as OpenAI if they start with openai/
return modelIdentifier.startsWith("openai/");
}
/**
* Upserts all loaded tools to the Letta server with retry logic.
* This registers Python stubs so the agent knows about the tools,
@@ -501,8 +552,7 @@ export async function executeTool(
(error.name === "AbortError" ||
error.message === "The operation was aborted" ||
// node:child_process AbortError may include code/message variants
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).code === "ABORT_ERR");
("code" in error && error.code === "ABORT_ERR"));
if (isAbort) {
return {
@@ -529,6 +579,14 @@ export function getToolNames(): string[] {
return Array.from(toolRegistry.keys());
}
/**
* Returns all Letta Code tool names known to this build, regardless of what is currently loaded.
* Useful for unlinking/removing tools when switching providers/models.
*/
export function getAllLettaToolNames(): string[] {
return [...TOOL_NAMES];
}
/**
* Gets all loaded tool schemas (for inspection/debugging).
*

View File

@@ -0,0 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Patch content in the ApplyPatch tool format, starting with '*** Begin Patch' and ending with '*** End Patch'."
}
},
"required": ["input"],
"additionalProperties": false
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for."
},
"include": {
"type": "string",
"description": "Optional glob that limits which files are searched (e.g. \"*.rs\" or \"*.{ts,tsx}\")."
},
"path": {
"type": "string",
"description": "Directory or file path to search. Defaults to the session's working directory."
},
"limit": {
"type": "number",
"description": "Maximum number of file paths to return (defaults to 100)."
}
},
"required": ["pattern"],
"additionalProperties": false
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"dir_path": {
"type": "string",
"description": "Absolute path to the directory to list."
},
"offset": {
"type": "number",
"description": "The entry number to start listing from. Must be 1 or greater."
},
"limit": {
"type": "number",
"description": "The maximum number of entries to return."
},
"depth": {
"type": "number",
"description": "The maximum directory depth to traverse. Must be 1 or greater."
}
},
"required": ["dir_path"],
"additionalProperties": false
}

View File

@@ -0,0 +1,51 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file."
},
"offset": {
"type": "number",
"description": "The line number to start reading from. Must be 1 or greater."
},
"limit": {
"type": "number",
"description": "The maximum number of lines to return."
},
"mode": {
"type": "string",
"description": "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" to expand around an anchor line."
},
"indentation": {
"type": "object",
"properties": {
"anchor_line": {
"type": "number",
"description": "Anchor line to center the indentation lookup on (defaults to offset)."
},
"max_levels": {
"type": "number",
"description": "How many parent indentation levels (smaller indents) to include."
},
"include_siblings": {
"type": "boolean",
"description": "When true, include additional blocks that share the anchor indentation."
},
"include_header": {
"type": "boolean",
"description": "Include doc comments or attributes directly above the selected block."
},
"max_lines": {
"type": "number",
"description": "Hard cap on the number of lines returned when using indentation mode."
}
},
"required": [],
"additionalProperties": false
}
},
"required": ["file_path"],
"additionalProperties": false
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
},
"description": "The command to execute as an array of shell arguments."
},
"workdir": {
"type": "string",
"description": "The working directory to execute the command in."
},
"timeout_ms": {
"type": "number",
"description": "The timeout for the command in milliseconds."
},
"with_escalated_permissions": {
"type": "boolean",
"description": "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions."
},
"justification": {
"type": "string",
"description": "Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command."
}
},
"required": ["command"],
"additionalProperties": false
}

View File

@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell script to execute in the user's default shell."
},
"workdir": {
"type": "string",
"description": "The working directory to execute the command in."
},
"timeout_ms": {
"type": "number",
"description": "The timeout for the command in milliseconds."
},
"with_escalated_permissions": {
"type": "boolean",
"description": "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions."
},
"justification": {
"type": "string",
"description": "Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command."
}
},
"required": ["command"],
"additionalProperties": false
}

View File

@@ -1,37 +1,55 @@
import ApplyPatchDescription from "./descriptions/ApplyPatch.md";
import BashDescription from "./descriptions/Bash.md";
import BashOutputDescription from "./descriptions/BashOutput.md";
import EditDescription from "./descriptions/Edit.md";
import ExitPlanModeDescription from "./descriptions/ExitPlanMode.md";
import GlobDescription from "./descriptions/Glob.md";
import GrepDescription from "./descriptions/Grep.md";
import GrepFilesDescription from "./descriptions/GrepFiles.md";
import KillBashDescription from "./descriptions/KillBash.md";
import ListDirCodexDescription from "./descriptions/ListDirCodex.md";
import LSDescription from "./descriptions/LS.md";
import MultiEditDescription from "./descriptions/MultiEdit.md";
import ReadDescription from "./descriptions/Read.md";
import ReadFileCodexDescription from "./descriptions/ReadFileCodex.md";
import ShellDescription from "./descriptions/Shell.md";
import ShellCommandDescription from "./descriptions/ShellCommand.md";
import TodoWriteDescription from "./descriptions/TodoWrite.md";
import WriteDescription from "./descriptions/Write.md";
import { apply_patch } from "./impl/ApplyPatch";
import { bash } from "./impl/Bash";
import { bash_output } from "./impl/BashOutput";
import { edit } from "./impl/Edit";
import { exit_plan_mode } from "./impl/ExitPlanMode";
import { glob } from "./impl/Glob";
import { grep } from "./impl/Grep";
import { grep_files } from "./impl/GrepFiles";
import { kill_bash } from "./impl/KillBash";
import { list_dir } from "./impl/ListDirCodex";
import { ls } from "./impl/LS";
import { multi_edit } from "./impl/MultiEdit";
import { read } from "./impl/Read";
import { read_file } from "./impl/ReadFileCodex";
import { shell } from "./impl/Shell";
import { shell_command } from "./impl/ShellCommand";
import { todo_write } from "./impl/TodoWrite";
import { write } from "./impl/Write";
import ApplyPatchSchema from "./schemas/ApplyPatch.json";
import BashSchema from "./schemas/Bash.json";
import BashOutputSchema from "./schemas/BashOutput.json";
import EditSchema from "./schemas/Edit.json";
import ExitPlanModeSchema from "./schemas/ExitPlanMode.json";
import GlobSchema from "./schemas/Glob.json";
import GrepSchema from "./schemas/Grep.json";
import GrepFilesSchema from "./schemas/GrepFiles.json";
import KillBashSchema from "./schemas/KillBash.json";
import ListDirCodexSchema from "./schemas/ListDirCodex.json";
import LSSchema from "./schemas/LS.json";
import MultiEditSchema from "./schemas/MultiEdit.json";
import ReadSchema from "./schemas/Read.json";
import ReadFileCodexSchema from "./schemas/ReadFileCodex.json";
import ShellSchema from "./schemas/Shell.json";
import ShellCommandSchema from "./schemas/ShellCommand.json";
import TodoWriteSchema from "./schemas/TodoWrite.json";
import WriteSchema from "./schemas/Write.json";
@@ -104,6 +122,36 @@ const toolDefinitions = {
description: WriteDescription.trim(),
impl: write as unknown as ToolImplementation,
},
shell_command: {
schema: ShellCommandSchema,
description: ShellCommandDescription.trim(),
impl: shell_command as unknown as ToolImplementation,
},
shell: {
schema: ShellSchema,
description: ShellDescription.trim(),
impl: shell as unknown as ToolImplementation,
},
read_file: {
schema: ReadFileCodexSchema,
description: ReadFileCodexDescription.trim(),
impl: read_file as unknown as ToolImplementation,
},
list_dir: {
schema: ListDirCodexSchema,
description: ListDirCodexDescription.trim(),
impl: list_dir as unknown as ToolImplementation,
},
grep_files: {
schema: GrepFilesSchema,
description: GrepFilesDescription.trim(),
impl: grep_files as unknown as ToolImplementation,
},
apply_patch: {
schema: ApplyPatchSchema,
description: ApplyPatchDescription.trim(),
impl: apply_patch as unknown as ToolImplementation,
},
} as const satisfies Record<string, ToolAssets>;
export type ToolName = keyof typeof toolDefinitions;

57
src/tools/toolset.ts Normal file
View File

@@ -0,0 +1,57 @@
import { getClient } from "../agent/client";
import { resolveModel } from "../agent/model";
import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
import { toolFilter } from "./filter";
import {
clearTools,
getToolNames,
isOpenAIModel,
loadTools,
upsertToolsToServer,
} from "./manager";
/**
* Switches the loaded toolset based on the target model identifier,
* upserts the tools to the server, and relinks them to the agent.
*
* @param modelIdentifier - The model handle/id
* @param agentId - Agent to relink tools to
* @param onNotice - Optional callback to emit a transcript notice
*/
export async function switchToolsetForModel(
modelIdentifier: string,
agentId: string,
): Promise<"codex" | "default"> {
// Resolve model ID to handle when possible so provider checks stay consistent
const resolvedModel = resolveModel(modelIdentifier) ?? modelIdentifier;
// Clear currently loaded tools and load the appropriate set for the target model
clearTools();
await loadTools(resolvedModel);
// If no tools were loaded (e.g., unexpected handle or edge-case filter),
// fall back to loading the default toolset to avoid ending up with only base tools.
const loadedAfterPrimary = getToolNames().length;
if (loadedAfterPrimary === 0 && !toolFilter.isActive()) {
await loadTools();
// If we *still* have no tools, surface an explicit error instead of silently
// leaving the agent with only base tools attached.
if (getToolNames().length === 0) {
throw new Error(
`Failed to load any Letta tools for model "${resolvedModel}".`,
);
}
}
// Upsert the new toolset (stored in the tool registry) to server
const client = await getClient();
await upsertToolsToServer(client);
// Remove old Letta tools and add new ones
await unlinkToolsFromAgent(agentId);
await linkToolsToAgent(agentId);
const toolsetName = isOpenAIModel(resolvedModel) ? "codex" : "default";
return toolsetName;
}

View File

@@ -45,3 +45,18 @@ export async function mkdir(
): Promise<void> {
mkdirSync(path, options);
}
export async function readJsonFile<T>(path: string): Promise<T> {
const text = await readFile(path);
return JSON.parse(text) as T;
}
export async function writeJsonFile(
path: string,
data: unknown,
options?: { indent?: number },
): Promise<void> {
const indent = options?.indent ?? 2;
const content = `${JSON.stringify(data, null, indent)}\n`;
await writeFile(path, content);
}