feat: Model based toolset switching (#111)
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
4
bun.lock
4
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=="],
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
23
src/tools/descriptions/ApplyPatch.md
Normal file
23
src/tools/descriptions/ApplyPatch.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
src/tools/descriptions/GrepFiles.md
Normal file
21
src/tools/descriptions/GrepFiles.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
19
src/tools/descriptions/ListDirCodex.md
Normal file
19
src/tools/descriptions/ListDirCodex.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
src/tools/descriptions/ReadFileCodex.md
Normal file
21
src/tools/descriptions/ReadFileCodex.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
src/tools/descriptions/Shell.md
Normal file
21
src/tools/descriptions/Shell.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
21
src/tools/descriptions/ShellCommand.md
Normal file
21
src/tools/descriptions/ShellCommand.md
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
269
src/tools/impl/ApplyPatch.ts
Normal file
269
src/tools/impl/ApplyPatch.ts
Normal 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(""),
|
||||
};
|
||||
}
|
||||
32
src/tools/impl/GrepFiles.ts
Normal file
32
src/tools/impl/GrepFiles.ts
Normal 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);
|
||||
}
|
||||
26
src/tools/impl/ListDirCodex.ts
Normal file
26
src/tools/impl/ListDirCodex.ts
Normal 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: [] });
|
||||
}
|
||||
42
src/tools/impl/ReadFileCodex.ts
Normal file
42
src/tools/impl/ReadFileCodex.ts
Normal 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
72
src/tools/impl/Shell.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/tools/impl/ShellCommand.ts
Normal file
70
src/tools/impl/ShellCommand.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
12
src/tools/schemas/ApplyPatch.json
Normal file
12
src/tools/schemas/ApplyPatch.json
Normal 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
|
||||
}
|
||||
24
src/tools/schemas/GrepFiles.json
Normal file
24
src/tools/schemas/GrepFiles.json
Normal 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
|
||||
}
|
||||
24
src/tools/schemas/ListDirCodex.json
Normal file
24
src/tools/schemas/ListDirCodex.json
Normal 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
|
||||
}
|
||||
51
src/tools/schemas/ReadFileCodex.json
Normal file
51
src/tools/schemas/ReadFileCodex.json
Normal 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
|
||||
}
|
||||
31
src/tools/schemas/Shell.json
Normal file
31
src/tools/schemas/Shell.json
Normal 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
|
||||
}
|
||||
28
src/tools/schemas/ShellCommand.json
Normal file
28
src/tools/schemas/ShellCommand.json
Normal 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
|
||||
}
|
||||
@@ -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
57
src/tools/toolset.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user