diff --git a/README.md b/README.md index 11ff160..7bc7953 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,13 @@ Join our [Discord](https://discord.gg/letta) to share feedback on persistence pa letta # Auto-resume project agent (or create new if first time) letta --new # Force create new agent letta --agent # Use specific agent ID +letta --model # Specify model (e.g., claude-sonnet-4.5, gpt-4o) +letta -m # Short form of --model letta --continue # Resume global last agent (deprecated, use project-based) ``` +> **Note:** The `--model` flag is inconsistent when resuming sessions. We recommend using the `/model` command instead to change models in interactive mode. + ### Headless Mode ```bash letta -p "Run bun lint and correct errors" # Auto-resumes project agent @@ -93,6 +97,7 @@ letta -p "Pick up where you left off" # Same - auto-resumes by letta -p "Start fresh" --new # Force new agent letta -p "Run all the test" --allowedTools "Bash" # Control tool permissions letta -p "Just read the code" --disallowedTools "Bash" # Control tool permissions +letta -p "Explain this code" -m gpt-4o # Use specific model # Pipe input from stdin echo "Explain this code" | letta -p diff --git a/src/agent/create.ts b/src/agent/create.ts index d6dad9b..503b886 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -7,6 +7,7 @@ import type { Block, CreateBlock, } from "@letta-ai/letta-client/resources/agents/agents"; +import { formatAvailableModels, resolveModel } from "../model"; import { loadProjectSettings, updateProjectSettings, @@ -19,9 +20,25 @@ import { SYSTEM_PROMPT } from "./promptAssets"; export async function createAgent( name = "letta-cli-agent", - model = "anthropic/claude-sonnet-4-5-20250929", + model?: string, embeddingModel = "openai/text-embedding-3-small", ) { + // Resolve model identifier to handle + let modelHandle: string; + if (model) { + const resolved = resolveModel(model); + if (!resolved) { + console.error(`Error: Unknown model "${model}"`); + console.error("Available models:"); + console.error(formatAvailableModels()); + process.exit(1); + } + modelHandle = resolved; + } else { + // Use default model + modelHandle = "anthropic/claude-sonnet-4-5-20250929"; + } + const client = await getClient(); // Get loaded tool names (tools are already registered with Letta) @@ -141,7 +158,7 @@ export async function createAgent( system: SYSTEM_PROMPT, name, embedding: embeddingModel, - model, + model: modelHandle, context_window_limit: 200_000, tools: toolNames, block_ids: blockIds, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index f09c2bf..9520e7c 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -989,8 +989,7 @@ export default function App({ try { // Find the selected model from models.json first (for loading message) - const modelsModule = await import("../models.json"); - const models = modelsModule.default; + const { models } = await import("../model"); const selectedModel = models.find((m) => m.id === modelId); if (!selectedModel) { diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index bc51156..a8e1abb 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -1,7 +1,7 @@ // Import useInput from vendored Ink for bracketed paste support import { Box, Text, useInput } from "ink"; import { useState } from "react"; -import models from "../../models.json"; +import { models } from "../../model"; import { colors } from "./colors"; interface ModelSelectorProps { diff --git a/src/headless.ts b/src/headless.ts index f16cec3..dd5a6f1 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -15,7 +15,7 @@ import { drainStream } from "./cli/helpers/stream"; import { loadSettings, updateSettings } from "./settings"; import { checkToolPermission, executeTool } from "./tools/manager"; -export async function handleHeadlessCommand(argv: string[]) { +export async function handleHeadlessCommand(argv: string[], model?: string) { const settings = await loadSettings(); // Parse CLI args @@ -70,7 +70,7 @@ export async function handleHeadlessCommand(argv: string[]) { // Priority 2: Check if --new flag was passed (skip all resume logic) if (!agent && forceNew) { - agent = await createAgent(); + agent = await createAgent(undefined, model); } // Priority 3: Try to resume from project settings (.letta/settings.local.json) @@ -101,7 +101,7 @@ export async function handleHeadlessCommand(argv: string[]) { // Priority 5: Create a new agent if (!agent) { - agent = await createAgent(); + agent = await createAgent(undefined, model); } // Save agent ID to both project and global settings diff --git a/src/index.ts b/src/index.ts index d03c72f..ad28898 100755 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ OPTIONS --new Force create new agent (skip auto-resume) -c, --continue Resume previous session (uses global lastAgent, deprecated) -a, --agent Use a specific agent ID + -m, --model Model ID or handle (e.g., "opus" or "anthropic/claude-opus-4-1-20250805") -p, --prompt Headless prompt mode --output-format Output format for headless mode (text, json, stream-json) Default: text @@ -65,6 +66,7 @@ async function main() { continue: { type: "boolean", short: "c" }, new: { type: "boolean" }, agent: { type: "string", short: "a" }, + model: { type: "string", short: "m" }, prompt: { type: "boolean", short: "p" }, run: { type: "boolean" }, tools: { type: "string" }, @@ -109,6 +111,7 @@ async function main() { const shouldContinue = (values.continue as boolean | undefined) ?? false; const forceNew = (values.new as boolean | undefined) ?? false; const specifiedAgentId = (values.agent as string | undefined) ?? null; + const specifiedModel = (values.model as string | undefined) ?? undefined; const isHeadless = values.prompt || values.run || !process.stdin.isTTY; // Validate API key early before any UI rendering @@ -174,7 +177,7 @@ async function main() { await upsertToolsToServer(client); const { handleHeadlessCommand } = await import("./headless"); - await handleHeadlessCommand(process.argv); + await handleHeadlessCommand(process.argv, specifiedModel); return; } @@ -189,10 +192,12 @@ async function main() { continueSession, forceNew, agentIdArg, + model, }: { continueSession: boolean; forceNew: boolean; agentIdArg: string | null; + model?: string; }) { const [loadingState, setLoadingState] = useState< "assembling" | "upserting" | "initializing" | "checking" | "ready" @@ -233,7 +238,7 @@ async function main() { // Priority 2: Check if --new flag was passed (skip all resume logic) if (!agent && forceNew) { // Create new agent, don't check any lastAgent fields - agent = await createAgent(); + agent = await createAgent(undefined, model); } // Priority 3: Try to resume from project settings (.letta/settings.local.json) @@ -265,7 +270,7 @@ async function main() { // Priority 5: Create a new agent if (!agent) { - agent = await createAgent(); + agent = await createAgent(undefined, model); } // Save agent ID to both project and global settings @@ -294,7 +299,7 @@ async function main() { } init(); - }, [continueSession, forceNew, agentIdArg]); + }, [continueSession, forceNew, agentIdArg, model]); if (!agentId) { return React.createElement(App, { @@ -323,6 +328,7 @@ async function main() { continueSession: shouldContinue, forceNew: forceNew, agentIdArg: specifiedAgentId, + model: specifiedModel, }), { exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..7248199 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,36 @@ +/** + * Model resolution and handling utilities + */ +import modelsData from "./models.json"; + +export const models = modelsData; + +/** + * Resolve a model by ID or handle + * @param modelIdentifier - Can be either a model ID (e.g., "opus") or a full handle (e.g., "anthropic/claude-opus-4-1-20250805") + * @returns The model handle if found, null otherwise + */ +export function resolveModel(modelIdentifier: string): string | null { + const byId = models.find((m) => m.id === modelIdentifier); + if (byId) return byId.handle; + + const byHandle = models.find((m) => m.handle === modelIdentifier); + if (byHandle) return byHandle.handle; + + return null; +} + +/** + * Get the default model handle + */ +export function getDefaultModel(): string { + const defaultModel = models.find((m) => m.isDefault); + return defaultModel?.handle || models[0].handle; +} + +/** + * Format available models for error messages + */ +export function formatAvailableModels(): string { + return models.map((m) => ` ${m.id.padEnd(20)} ${m.handle}`).join("\n"); +} diff --git a/src/tests/tools/bash.test.ts b/src/tests/tools/bash.test.ts index ac7d6f0..49ff39c 100644 --- a/src/tests/tools/bash.test.ts +++ b/src/tests/tools/bash.test.ts @@ -80,7 +80,7 @@ describe("Bash tool", () => { }); test("throws error when command is missing", async () => { - await expect(bash({} as any)).rejects.toThrow( + await expect(bash({} as Parameters[0])).rejects.toThrow( /missing required parameter.*command/, ); }); diff --git a/src/tests/tools/edit.test.ts b/src/tests/tools/edit.test.ts index cf77dac..9141dcf 100644 --- a/src/tests/tools/edit.test.ts +++ b/src/tests/tools/edit.test.ts @@ -71,7 +71,7 @@ describe("Edit tool", () => { edit({ old_string: "foo", new_string: "bar", - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*file_path/); }); @@ -83,7 +83,7 @@ describe("Edit tool", () => { edit({ file_path: file, new_string: "bar", - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*old_string/); }); @@ -95,7 +95,7 @@ describe("Edit tool", () => { edit({ file_path: file, old_string: "foo", - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*new_string/); }); @@ -108,7 +108,7 @@ describe("Edit tool", () => { file_path: file, old_string: "World", new_str: "Bun", - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*new_string/); }); }); diff --git a/src/tests/tools/grep.test.ts b/src/tests/tools/grep.test.ts index 8d20621..c4fa69e 100644 --- a/src/tests/tools/grep.test.ts +++ b/src/tests/tools/grep.test.ts @@ -58,7 +58,7 @@ describe("Grep tool", () => { }); test("throws error when pattern is missing", async () => { - await expect(grep({} as any)).rejects.toThrow( + await expect(grep({} as Parameters[0])).rejects.toThrow( /missing required parameter.*pattern/, ); }); diff --git a/src/tests/tools/ls.test.ts b/src/tests/tools/ls.test.ts index 19fa6da..276916c 100644 --- a/src/tests/tools/ls.test.ts +++ b/src/tests/tools/ls.test.ts @@ -47,7 +47,7 @@ describe("LS tool", () => { }); test("throws error when path is missing", async () => { - await expect(ls({} as any)).rejects.toThrow( + await expect(ls({} as Parameters[0])).rejects.toThrow( /missing required parameter.*path/, ); }); diff --git a/src/tests/tools/multiedit.test.ts b/src/tests/tools/multiedit.test.ts index b927391..26a9529 100644 --- a/src/tests/tools/multiedit.test.ts +++ b/src/tests/tools/multiedit.test.ts @@ -45,7 +45,7 @@ describe("MultiEdit tool", () => { await expect( multi_edit({ edits: [{ old_string: "foo", new_string: "bar" }], - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*file_path/); }); @@ -56,7 +56,7 @@ describe("MultiEdit tool", () => { await expect( multi_edit({ file_path: file, - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*edits/); }); @@ -67,7 +67,9 @@ describe("MultiEdit tool", () => { await expect( multi_edit({ file_path: file, - edits: [{ new_string: "bar" } as any], + edits: [ + { new_string: "bar" } as Parameters[0]["edits"][0], + ], }), ).rejects.toThrow(/missing required parameter.*old_string/); }); @@ -79,7 +81,9 @@ describe("MultiEdit tool", () => { await expect( multi_edit({ file_path: file, - edits: [{ old_string: "foo" } as any], + edits: [ + { old_string: "foo" } as Parameters[0]["edits"][0], + ], }), ).rejects.toThrow(/missing required parameter.*new_string/); }); @@ -91,7 +95,11 @@ describe("MultiEdit tool", () => { await expect( multi_edit({ file_path: file, - edits: [{ old_string: "foo", new_str: "baz" } as any], + edits: [ + { old_string: "foo", new_str: "baz" } as Parameters< + typeof multi_edit + >[0]["edits"][0], + ], }), ).rejects.toThrow(/missing required parameter.*new_string/); }); diff --git a/src/tests/tools/read.test.ts b/src/tests/tools/read.test.ts index 5259c0d..48be41d 100644 --- a/src/tests/tools/read.test.ts +++ b/src/tests/tools/read.test.ts @@ -104,7 +104,7 @@ export default box; }); test("throws error when file_path is missing", async () => { - await expect(read({} as any)).rejects.toThrow( + await expect(read({} as Parameters[0])).rejects.toThrow( /missing required parameter.*file_path/, ); }); diff --git a/src/tests/tools/write.test.ts b/src/tests/tools/write.test.ts index 9ff45a8..4c3a324 100644 --- a/src/tests/tools/write.test.ts +++ b/src/tests/tools/write.test.ts @@ -53,7 +53,7 @@ describe("Write tool", () => { await expect( write({ content: "Hello", - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*file_path/); }); @@ -64,7 +64,7 @@ describe("Write tool", () => { await expect( write({ file_path: filePath, - } as any), + } as Parameters[0]), ).rejects.toThrow(/missing required parameter.*content/); }); });