feat: add --model flag (#32)

This commit is contained in:
Devansh Jain
2025-10-30 16:07:21 -07:00
committed by GitHub
parent f71d2c9b66
commit 09e4f0b13a
14 changed files with 98 additions and 27 deletions

View File

@@ -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 <id> # Use specific agent ID
letta --model <model> # Specify model (e.g., claude-sonnet-4.5, gpt-4o)
letta -m <model> # 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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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

View File

@@ -28,6 +28,7 @@ OPTIONS
--new Force create new agent (skip auto-resume)
-c, --continue Resume previous session (uses global lastAgent, deprecated)
-a, --agent <id> Use a specific agent ID
-m, --model <id> Model ID or handle (e.g., "opus" or "anthropic/claude-opus-4-1-20250805")
-p, --prompt Headless prompt mode
--output-format <fmt> 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

36
src/model.ts Normal file
View File

@@ -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");
}

View File

@@ -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<typeof bash>[0])).rejects.toThrow(
/missing required parameter.*command/,
);
});

View File

@@ -71,7 +71,7 @@ describe("Edit tool", () => {
edit({
old_string: "foo",
new_string: "bar",
} as any),
} as Parameters<typeof edit>[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<typeof edit>[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<typeof edit>[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<typeof edit>[0]),
).rejects.toThrow(/missing required parameter.*new_string/);
});
});

View File

@@ -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<typeof grep>[0])).rejects.toThrow(
/missing required parameter.*pattern/,
);
});

View File

@@ -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<typeof ls>[0])).rejects.toThrow(
/missing required parameter.*path/,
);
});

View File

@@ -45,7 +45,7 @@ describe("MultiEdit tool", () => {
await expect(
multi_edit({
edits: [{ old_string: "foo", new_string: "bar" }],
} as any),
} as Parameters<typeof multi_edit>[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<typeof multi_edit>[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<typeof multi_edit>[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<typeof multi_edit>[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/);
});

View File

@@ -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<typeof read>[0])).rejects.toThrow(
/missing required parameter.*file_path/,
);
});

View File

@@ -53,7 +53,7 @@ describe("Write tool", () => {
await expect(
write({
content: "Hello",
} as any),
} as Parameters<typeof write>[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<typeof write>[0]),
).rejects.toThrow(/missing required parameter.*content/);
});
});