diff --git a/bun.lock b/bun.lock index 6931ce1..c107100 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 039d59f..7b85f83 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/agent/approval-execution.ts b/src/agent/approval-execution.ts index d01412f..661fcd3 100644 --- a/src/agent/approval-execution.ts +++ b/src/agent/approval-execution.ts @@ -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. diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index ae86fbc..64c24a4 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -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 || "", diff --git a/src/agent/create.ts b/src/agent/create.ts index f1f555f..fb63816 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -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: diff --git a/src/agent/modify.ts b/src/agent/modify.ts index cf0c34e..ee436db 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -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, preserveParallelToolCalls?: boolean, ): Promise { 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), + ...(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 { 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 { // 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 { })), ]; - 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, }); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8004cf8..3862e79 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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, }); diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts index 9600b4d..9520b13 100644 --- a/src/cli/helpers/backfill.ts +++ b/src/cli/helpers/backfill.ts @@ -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(); diff --git a/src/index.ts b/src/index.ts index 91a0cbf..5c9b328 100755 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ OPTIONS --skills 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(); diff --git a/src/permissions/analyzer.ts b/src/permissions/analyzer.ts index 2fd3f55..a169ca0 100644 --- a/src/permissions/analyzer.ts +++ b/src/permissions/analyzer.ts @@ -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, diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index 92e9cb4..39cf60c 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -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); } diff --git a/src/tests/agent/link-unlink.test.ts b/src/tests/agent/link-unlink.test.ts index 11483bd..7a4817b 100644 --- a/src/tests/agent/link-unlink.test.ts +++ b/src/tests/agent/link-unlink.test.ts @@ -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", ); diff --git a/src/tools/descriptions/ApplyPatch.md b/src/tools/descriptions/ApplyPatch.md new file mode 100644 index 0000000..ad4368a --- /dev/null +++ b/src/tools/descriptions/ApplyPatch.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/descriptions/GrepFiles.md b/src/tools/descriptions/GrepFiles.md new file mode 100644 index 0000000..901a332 --- /dev/null +++ b/src/tools/descriptions/GrepFiles.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/descriptions/ListDirCodex.md b/src/tools/descriptions/ListDirCodex.md new file mode 100644 index 0000000..2a9c0ff --- /dev/null +++ b/src/tools/descriptions/ListDirCodex.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/descriptions/ReadFileCodex.md b/src/tools/descriptions/ReadFileCodex.md new file mode 100644 index 0000000..cf45a8b --- /dev/null +++ b/src/tools/descriptions/ReadFileCodex.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/descriptions/Shell.md b/src/tools/descriptions/Shell.md new file mode 100644 index 0000000..6ca21b2 --- /dev/null +++ b/src/tools/descriptions/Shell.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/descriptions/ShellCommand.md b/src/tools/descriptions/ShellCommand.md new file mode 100644 index 0000000..230c13e --- /dev/null +++ b/src/tools/descriptions/ShellCommand.md @@ -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. + + + + + + + + + + + + + diff --git a/src/tools/impl/ApplyPatch.ts b/src/tools/impl/ApplyPatch.ts new file mode 100644 index 0000000..41510d1 --- /dev/null +++ b/src/tools/impl/ApplyPatch.ts @@ -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 { + 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(); + + // Helper to get current content (including prior ops in this patch) + const loadFile = async (relativePath: string): Promise => { + 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(""), + }; +} diff --git a/src/tools/impl/GrepFiles.ts b/src/tools/impl/GrepFiles.ts new file mode 100644 index 0000000..83098f5 --- /dev/null +++ b/src/tools/impl/GrepFiles.ts @@ -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>; + +/** + * 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 { + 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); +} diff --git a/src/tools/impl/ListDirCodex.ts b/src/tools/impl/ListDirCodex.ts new file mode 100644 index 0000000..07468a4 --- /dev/null +++ b/src/tools/impl/ListDirCodex.ts @@ -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>; + +/** + * 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 { + validateRequiredParams(args, ["dir_path"], "list_dir"); + + const { dir_path } = args; + + // LS handles path resolution and formatting. + return ls({ path: dir_path, ignore: [] }); +} diff --git a/src/tools/impl/ReadFileCodex.ts b/src/tools/impl/ReadFileCodex.ts new file mode 100644 index 0000000..4965a8a --- /dev/null +++ b/src/tools/impl/ReadFileCodex.ts @@ -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 { + validateRequiredParams(args, ["file_path"], "read_file"); + + const { file_path, offset, limit } = args; + + const result = await read({ + file_path, + offset, + limit, + }); + + return { content: result.content }; +} diff --git a/src/tools/impl/Shell.ts b/src/tools/impl/Shell.ts new file mode 100644 index 0000000..61bb20d --- /dev/null +++ b/src/tools/impl/Shell.ts @@ -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 { + 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; + } + } + } +} diff --git a/src/tools/impl/ShellCommand.ts b/src/tools/impl/ShellCommand.ts new file mode 100644 index 0000000..48a70f3 --- /dev/null +++ b/src/tools/impl/ShellCommand.ts @@ -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 { + 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; + } + } + } +} diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 7349107..d2dfdf2 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -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 = { Bash: { requiresApproval: true }, @@ -21,6 +46,12 @@ const TOOL_PERMISSIONS: Record = { 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 { +export async function loadTools(modelIdentifier?: string): Promise { 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 { } } +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). * diff --git a/src/tools/schemas/ApplyPatch.json b/src/tools/schemas/ApplyPatch.json new file mode 100644 index 0000000..671acec --- /dev/null +++ b/src/tools/schemas/ApplyPatch.json @@ -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 +} diff --git a/src/tools/schemas/GrepFiles.json b/src/tools/schemas/GrepFiles.json new file mode 100644 index 0000000..09c2616 --- /dev/null +++ b/src/tools/schemas/GrepFiles.json @@ -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 +} diff --git a/src/tools/schemas/ListDirCodex.json b/src/tools/schemas/ListDirCodex.json new file mode 100644 index 0000000..368a178 --- /dev/null +++ b/src/tools/schemas/ListDirCodex.json @@ -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 +} diff --git a/src/tools/schemas/ReadFileCodex.json b/src/tools/schemas/ReadFileCodex.json new file mode 100644 index 0000000..87892c3 --- /dev/null +++ b/src/tools/schemas/ReadFileCodex.json @@ -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 +} diff --git a/src/tools/schemas/Shell.json b/src/tools/schemas/Shell.json new file mode 100644 index 0000000..b34dddd --- /dev/null +++ b/src/tools/schemas/Shell.json @@ -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 +} diff --git a/src/tools/schemas/ShellCommand.json b/src/tools/schemas/ShellCommand.json new file mode 100644 index 0000000..0afac52 --- /dev/null +++ b/src/tools/schemas/ShellCommand.json @@ -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 +} diff --git a/src/tools/toolDefinitions.ts b/src/tools/toolDefinitions.ts index 8d2e0d1..a4e5c28 100644 --- a/src/tools/toolDefinitions.ts +++ b/src/tools/toolDefinitions.ts @@ -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; export type ToolName = keyof typeof toolDefinitions; diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts new file mode 100644 index 0000000..1905162 --- /dev/null +++ b/src/tools/toolset.ts @@ -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; +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 73a0fd6..6de0a86 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -45,3 +45,18 @@ export async function mkdir( ): Promise { mkdirSync(path, options); } + +export async function readJsonFile(path: string): Promise { + const text = await readFile(path); + return JSON.parse(text) as T; +} + +export async function writeJsonFile( + path: string, + data: unknown, + options?: { indent?: number }, +): Promise { + const indent = options?.indent ?? 2; + const content = `${JSON.stringify(data, null, indent)}\n`; + await writeFile(path, content); +}