From 277ce3cd417d5465a322ffbd22b187201567f4b7 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 19 Feb 2026 12:47:03 -0800 Subject: [PATCH] fix: recover default agent creation when base tools are missing (#1039) --- src/agent/create.ts | 103 +++++++++++++++++- .../agent/create-base-tools-recovery.test.ts | 96 ++++++++++++++++ 2 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/tests/agent/create-base-tools-recovery.test.ts diff --git a/src/agent/create.ts b/src/agent/create.ts index f77cb09..0242063 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -7,8 +7,10 @@ import type { AgentType, } from "@letta-ai/letta-client/resources/agents/agents"; import { DEFAULT_AGENT_NAME } from "../constants"; +import { settingsManager } from "../settings-manager"; import { getModelContextWindow } from "./available-models"; -import { getClient } from "./client"; +import { getClient, getServerUrl } from "./client"; +import { getLettaCodeHeaders } from "./http-headers"; import { getDefaultMemoryBlocks } from "./memory"; import { type MemoryPromptMode, reconcileMemoryPrompt } from "./memoryPrompt"; import { @@ -45,6 +47,88 @@ export interface CreateAgentResult { provenance: AgentProvenance; } +function isToolsNotFoundError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const status = (err as { status?: unknown } | null)?.status; + + return ( + typeof message === "string" && + /tools not found by name/i.test(message) && + /memory_apply_patch|memory|web_search|fetch_webpage/i.test(message) && + (status === undefined || status === 400) + ); +} + +async function addBaseToolsToServer(): Promise { + const settings = await settingsManager.getSettingsWithSecureTokens(); + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + if (!apiKey) { + console.warn( + "Cannot auto-populate base tools: missing LETTA_API_KEY for manual endpoint call.", + ); + return false; + } + + try { + const response = await fetch(`${getServerUrl()}/v1/tools/add-base-tools`, { + method: "POST", + headers: getLettaCodeHeaders(apiKey), + }); + + if (!response.ok) { + const body = await response.text(); + console.warn( + `Failed to add base tools via /v1/tools/add-base-tools (${response.status}): ${body || response.statusText}`, + ); + return false; + } + + return true; + } catch (err) { + console.warn( + `Failed to call /v1/tools/add-base-tools: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + +type CreateWithToolsFn = (tools: string[]) => Promise; +type AddBaseToolsFn = () => Promise; + +export async function createAgentWithBaseToolsRecovery( + createWithTools: CreateWithToolsFn, + toolNames: string[], + addBaseTools: AddBaseToolsFn = addBaseToolsToServer, +): Promise { + try { + return await createWithTools(toolNames); + } catch (err) { + if (!isToolsNotFoundError(err)) { + throw err; + } + + console.warn( + "Agent creation failed due to missing base tools. Attempting to add base tools on server...", + ); + await addBaseTools(); + + try { + return await createWithTools(toolNames); + } catch (retryErr) { + console.warn( + `Agent creation still failed after base-tool bootstrap: ${ + retryErr instanceof Error ? retryErr.message : String(retryErr) + }`, + ); + console.warn( + "Retrying agent creation with no server-side tools attached.", + ); + return await createWithTools([]); + } + } +} + export interface CreateAgentOptions { name?: string; /** Agent description shown in /agents selector */ @@ -306,7 +390,7 @@ export async function createAgent( const agentDescription = options.description ?? `Letta Code agent created in ${process.cwd()}`; - const agent = await client.agents.create({ + const createAgentRequestBase = { agent_type: "letta_v1_agent" as AgentType, system: systemPromptContent, name, @@ -314,7 +398,6 @@ export async function createAgent( embedding: embeddingModelVal || undefined, model: modelHandle, ...(contextWindow && { context_window_limit: contextWindow }), - tools: toolNames, // New blocks created inline with agent (saves ~2s of sequential API calls) memory_blocks: filteredMemoryBlocks.length > 0 ? filteredMemoryBlocks : undefined, @@ -328,7 +411,19 @@ export async function createAgent( initial_message_sequence: [], parallel_tool_calls: parallelToolCallsVal, enable_sleeptime: enableSleeptimeVal, - }); + }; + + const createWithTools = (tools: string[]) => + client.agents.create({ + ...createAgentRequestBase, + tools, + }); + + const agent = await createAgentWithBaseToolsRecovery( + createWithTools, + toolNames, + addBaseToolsToServer, + ); // Note: Preflight check above falls back to 'memory' when 'memory_apply_patch' is unavailable. diff --git a/src/tests/agent/create-base-tools-recovery.test.ts b/src/tests/agent/create-base-tools-recovery.test.ts new file mode 100644 index 0000000..c9a5089 --- /dev/null +++ b/src/tests/agent/create-base-tools-recovery.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; +import { createAgentWithBaseToolsRecovery } from "../../agent/create"; + +function missingBaseToolsError(): Error & { status: number } { + return Object.assign( + new Error( + `400 {"detail":"Tools not found by name: {'fetch_webpage', 'memory'}"}`, + ), + { status: 400 }, + ); +} + +describe("createAgentWithBaseToolsRecovery", () => { + const mkAgent = (id: string): AgentState => ({ id }) as unknown as AgentState; + + test("bootstraps base tools then retries with original tools", async () => { + const createWithTools = mock((_tools: string[]) => { + if (createWithTools.mock.calls.length === 1) { + return Promise.reject(missingBaseToolsError()); + } + return Promise.resolve(mkAgent("agent-retry-success")); + }); + const addBaseTools = mock(() => Promise.resolve(true)); + + const agent = await createAgentWithBaseToolsRecovery( + createWithTools, + ["memory", "web_search", "fetch_webpage"], + addBaseTools, + ); + + expect(agent.id).toBe("agent-retry-success"); + expect(addBaseTools).toHaveBeenCalledTimes(1); + expect(createWithTools).toHaveBeenCalledTimes(2); + expect(createWithTools.mock.calls[0]?.[0]).toEqual([ + "memory", + "web_search", + "fetch_webpage", + ]); + expect(createWithTools.mock.calls[1]?.[0]).toEqual([ + "memory", + "web_search", + "fetch_webpage", + ]); + }); + + test("falls back to create with no server-side tools after second failure", async () => { + const createWithTools = mock((_tools: string[]) => { + if (createWithTools.mock.calls.length <= 2) { + return Promise.reject( + createWithTools.mock.calls.length === 1 + ? missingBaseToolsError() + : new Error("still failing after bootstrap"), + ); + } + return Promise.resolve(mkAgent("agent-no-tools")); + }); + const addBaseTools = mock(() => Promise.resolve(true)); + + const agent = await createAgentWithBaseToolsRecovery( + createWithTools, + ["memory", "web_search", "fetch_webpage"], + addBaseTools, + ); + + expect(agent.id).toBe("agent-no-tools"); + expect(addBaseTools).toHaveBeenCalledTimes(1); + expect(createWithTools).toHaveBeenCalledTimes(3); + expect(createWithTools.mock.calls[2]?.[0]).toEqual([]); + }); + + test("does not bootstrap for unrelated missing-tool errors", async () => { + const createWithTools = mock(() => + Promise.reject( + Object.assign( + new Error( + `400 {"detail":"Tools not found by name: {'custom_tool'}"}`, + ), + { status: 400 }, + ), + ), + ); + const addBaseTools = mock(() => Promise.resolve(true)); + + await expect( + createAgentWithBaseToolsRecovery( + createWithTools, + ["custom_tool"], + addBaseTools, + ), + ).rejects.toThrow("custom_tool"); + + expect(addBaseTools).not.toHaveBeenCalled(); + expect(createWithTools).toHaveBeenCalledTimes(1); + }); +});