From 5fac4bc106fb8f4f1454496429520abf587d355b Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 18 Feb 2026 13:43:40 -0800 Subject: [PATCH] feat: persist toolset mode and add auto option (#1015) --- src/cli/App.tsx | 187 ++++++++++++-------- src/cli/components/ToolsetSelector.tsx | 228 +++++++++++-------------- src/index.ts | 27 +-- src/settings-manager.ts | 49 ++++++ src/tests/settings-manager.test.ts | 34 ++++ src/tests/startup-flow.test.ts | 11 ++ src/tools/toolset.ts | 27 +-- 7 files changed, 333 insertions(+), 230 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 0ac0e78..e4621cd 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -89,11 +89,10 @@ import { analyzeToolApproval, checkToolPermission, executeTool, - isGeminiModel, - isOpenAIModel, savePermissionRule, type ToolExecutionResult, } from "../tools/manager"; +import type { ToolsetName, ToolsetPreference } from "../tools/toolset"; import { debugLog, debugWarn } from "../utils/debug"; import { handleMcpAdd, @@ -1239,13 +1238,7 @@ export default function App({ } | { type: "switch_toolset"; - toolsetId: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none"; + toolsetId: ToolsetPreference; commandId?: string; } | { type: "switch_system"; promptId: string; commandId?: string } @@ -1263,15 +1256,11 @@ export default function App({ const [currentSystemPromptId, setCurrentSystemPromptId] = useState< string | null >("default"); - const [currentToolset, setCurrentToolset] = useState< - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none" - | null - >(null); + const [currentToolset, setCurrentToolset] = useState( + null, + ); + const [currentToolsetPreference, setCurrentToolsetPreference] = + useState("auto"); const [llmConfig, setLlmConfig] = useState(null); const llmConfigRef = useRef(llmConfig); useEffect(() => { @@ -1279,7 +1268,7 @@ export default function App({ }, [llmConfig]); const [currentModelId, setCurrentModelId] = useState(null); // Full model handle for API calls (e.g., "anthropic/claude-sonnet-4-5-20251101") - const [_currentModelHandle, setCurrentModelHandle] = useState( + const [currentModelHandle, setCurrentModelHandle] = useState( null, ); // Derive agentName from agentState (single source of truth) @@ -2699,14 +2688,27 @@ export default function App({ // Store full handle for API calls (e.g., compaction) setCurrentModelHandle(agentModelHandle || null); - // Derive toolset from agent's model (not persisted, computed on resume) - if (agentModelHandle) { - const derivedToolset = isOpenAIModel(agentModelHandle) - ? "codex" - : isGeminiModel(agentModelHandle) - ? "gemini" - : "default"; - setCurrentToolset(derivedToolset); + const persistedToolsetPreference = + settingsManager.getToolsetPreference(agentId); + setCurrentToolsetPreference(persistedToolsetPreference); + + if (persistedToolsetPreference === "auto") { + if (agentModelHandle) { + const { switchToolsetForModel } = await import( + "../tools/toolset" + ); + const derivedToolset = await switchToolsetForModel( + agentModelHandle, + agentId, + ); + setCurrentToolset(derivedToolset); + } else { + setCurrentToolset(null); + } + } else { + const { forceToolsetSwitch } = await import("../tools/toolset"); + await forceToolsetSwitch(persistedToolsetPreference, agentId); + setCurrentToolset(persistedToolsetPreference); } } catch (error) { console.error("Error fetching agent config:", error); @@ -5156,6 +5158,13 @@ export default function App({ setAgentId(targetAgentId); setAgentState(agent); setLlmConfig(agent.llm_config); + const agentModelHandle = + agent.llm_config.model_endpoint_type && agent.llm_config.model + ? agent.llm_config.model_endpoint_type + + "/" + + agent.llm_config.model + : (agent.llm_config.model ?? null); + setCurrentModelHandle(agentModelHandle); setConversationId(targetConversationId); // Ensure bootstrap reminders are re-injected on the first user turn @@ -5290,6 +5299,13 @@ export default function App({ setAgentId(agent.id); setAgentState(agent); setLlmConfig(agent.llm_config); + const agentModelHandle = + agent.llm_config.model_endpoint_type && agent.llm_config.model + ? agent.llm_config.model_endpoint_type + + "/" + + agent.llm_config.model + : (agent.llm_config.model ?? null); + setCurrentModelHandle(agentModelHandle); // Reset context token tracking for new agent resetContextHistory(contextTrackerRef.current); @@ -9793,42 +9809,42 @@ ${SYSTEM_REMINDER_CLOSE} // Reset context token tracking since different models have different tokenizers resetContextHistory(contextTrackerRef.current); + setCurrentModelHandle(modelHandle); - const { isOpenAIModel, isGeminiModel } = await import( - "../tools/manager" - ); - const targetToolset: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none" = isOpenAIModel(modelHandle) - ? "codex" - : isGeminiModel(modelHandle) - ? "gemini" - : "default"; + const persistedToolsetPreference = + settingsManager.getToolsetPreference(agentId); + let toolsetNoticeLine: string | null = null; - let toolsetName: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none" - | null = null; - if (currentToolset !== targetToolset) { + if (persistedToolsetPreference === "auto") { const { switchToolsetForModel } = await import("../tools/toolset"); - toolsetName = await switchToolsetForModel(modelHandle, agentId); + const toolsetName = await switchToolsetForModel( + modelHandle, + agentId, + ); + setCurrentToolsetPreference("auto"); setCurrentToolset(toolsetName); + toolsetNoticeLine = + "Auto toolset selected: switched to " + + toolsetName + + ". Use /toolset to set a manual override."; + } else { + const { forceToolsetSwitch } = await import("../tools/toolset"); + if (currentToolset !== persistedToolsetPreference) { + await forceToolsetSwitch(persistedToolsetPreference, agentId); + setCurrentToolset(persistedToolsetPreference); + } + setCurrentToolsetPreference(persistedToolsetPreference); + toolsetNoticeLine = + "Manual toolset override remains active: " + + persistedToolsetPreference + + "."; } - const autoToolsetLine = toolsetName - ? `Automatically switched toolset to ${toolsetName}. Use /toolset to change back if desired.\nConsider switching to a different system prompt using /system to match.` - : null; const outputLines = [ - `Switched to ${model.label}${reasoningLevel ? ` (${reasoningLevel} reasoning)` : ""}`, - ...(autoToolsetLine ? [autoToolsetLine] : []), + "Switched to " + + model.label + + (reasoningLevel ? ` (${reasoningLevel} reasoning)` : ""), + ...(toolsetNoticeLine ? [toolsetNoticeLine] : []), ].join("\n"); cmd.finish(outputLines, true); @@ -10020,16 +10036,7 @@ ${SYSTEM_REMINDER_CLOSE} ); const handleToolsetSelect = useCallback( - async ( - toolsetId: - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none", - commandId?: string | null, - ) => { + async (toolsetId: ToolsetPreference, commandId?: string | null) => { const overlayCommand = commandId ? commandRunner.getHandle(commandId, "/toolset") : consumeOverlayCommand("toolset"); @@ -10058,20 +10065,51 @@ ${SYSTEM_REMINDER_CLOSE} await withCommandLock(async () => { const cmd = overlayCommand ?? - commandRunner.start( - "/toolset", - `Switching toolset to ${toolsetId}...`, - ); + commandRunner.start("/toolset", "Switching toolset..."); cmd.update({ - output: `Switching toolset to ${toolsetId}...`, + output: "Switching toolset...", phase: "running", }); try { - const { forceToolsetSwitch } = await import("../tools/toolset"); + const { forceToolsetSwitch, switchToolsetForModel } = await import( + "../tools/toolset" + ); + + if (toolsetId === "auto") { + const modelHandle = + currentModelHandle ?? + (llmConfig?.model_endpoint_type && llmConfig?.model + ? `${llmConfig.model_endpoint_type}/${llmConfig.model}` + : (llmConfig?.model ?? null)); + if (!modelHandle) { + throw new Error( + "Could not determine current model for auto toolset", + ); + } + + const derivedToolset = await switchToolsetForModel( + modelHandle, + agentId, + ); + settingsManager.setToolsetPreference(agentId, "auto"); + setCurrentToolsetPreference("auto"); + setCurrentToolset(derivedToolset); + cmd.finish( + `Toolset mode set to auto (currently ${derivedToolset}).`, + true, + ); + return; + } + await forceToolsetSwitch(toolsetId, agentId); + settingsManager.setToolsetPreference(agentId, toolsetId); + setCurrentToolsetPreference(toolsetId); setCurrentToolset(toolsetId); - cmd.finish(`Switched toolset to ${toolsetId}`, true); + cmd.finish( + `Switched toolset to ${toolsetId} (manual override)`, + true, + ); } catch (error) { const errorDetails = formatErrorDetails(error, agentId); cmd.fail(`Failed to switch toolset: ${errorDetails}`); @@ -10082,7 +10120,9 @@ ${SYSTEM_REMINDER_CLOSE} agentId, commandRunner, consumeOverlayCommand, + currentModelHandle, isAgentBusy, + llmConfig, withCommandLock, ], ); @@ -11337,6 +11377,7 @@ Plan file path: ${planFilePath}`; {activeOverlay === "toolset" && ( diff --git a/src/cli/components/ToolsetSelector.tsx b/src/cli/components/ToolsetSelector.tsx index 741ba75..63e1e2b 100644 --- a/src/cli/components/ToolsetSelector.tsx +++ b/src/cli/components/ToolsetSelector.tsx @@ -1,6 +1,7 @@ // Import useInput from vendored Ink for bracketed paste support import { Box, useInput } from "ink"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { ToolsetName, ToolsetPreference } from "../../tools/toolset"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { Text } from "./Text"; @@ -8,114 +9,86 @@ import { Text } from "./Text"; // Horizontal line character (matches approval dialogs) const SOLID_LINE = "─"; -type ToolsetId = - | "codex" - | "codex_snake" - | "default" - | "gemini" - | "gemini_snake" - | "none"; - interface ToolsetOption { - id: ToolsetId; + id: ToolsetPreference; label: string; description: string; - tools: string[]; isFeatured?: boolean; } const toolsets: ToolsetOption[] = [ + { + id: "auto", + label: "Auto", + description: "Auto-select based on the model", + isFeatured: true, + }, + { + id: "none", + label: "None", + description: "Remove all Letta Code tools from your agent", + isFeatured: true, + }, { id: "default", - label: "Default Tools", - description: "Toolset optimized for Claude models", - tools: [ - "Bash", - "TaskOutput", - "Edit", - "Glob", - "Grep", - "LS", - "MultiEdit", - "Read", - "TodoWrite", - "Write", - ], + label: "Claude toolset", + description: "Optimized for Anthropic models", isFeatured: true, }, { id: "codex", - label: "Codex Tools", - description: "Toolset optimized for GPT/Codex models", - tools: [ - "AskUserQuestion", - "EnterPlanMode", - "ExitPlanMode", - "Task", - "Skill", - "ShellCommand", - "ApplyPatch", - "UpdatePlan", - "ViewImage", - ], + label: "Codex toolset", + description: "Optimized for GPT/Codex models", + isFeatured: true, + }, + { + id: "gemini", + label: "Gemini toolset", + description: "Optimized for Google Gemini models", isFeatured: true, }, { id: "codex_snake", - label: "Codex Tools (snake_case)", - description: "Toolset optimized for GPT/Codex models (snake_case)", - tools: ["shell_command", "apply_patch", "update_plan", "view_image"], - }, - { - id: "gemini", - label: "Gemini Tools", - description: "Toolset optimized for Gemini models", - tools: [ - "RunShellCommand", - "ReadFileGemini", - "ListDirectory", - "GlobGemini", - "SearchFileContent", - "Replace", - "WriteFileGemini", - "WriteTodos", - "ReadManyFiles", - ], - isFeatured: true, + label: "Codex toolset (snake_case)", + description: "Optimized for GPT/Codex models (snake_case)", }, { id: "gemini_snake", - label: "Gemini Tools (snake_case)", - description: "Toolset optimized for Gemini models (snake_case)", - tools: [ - "run_shell_command", - "read_file_gemini", - "list_directory", - "glob_gemini", - "search_file_content", - "replace", - "write_file_gemini", - "write_todos", - "read_many_files", - ], - }, - { - id: "none", - label: "None (Disable Tools)", - description: "Remove all Letta Code tools from the agent", - tools: [], - isFeatured: true, + label: "Gemini toolset (snake_case)", + description: "Optimized for Google Gemini models (snake_case)", }, ]; +function formatEffectiveToolset(toolset?: ToolsetName): string { + if (!toolset) return "Unknown"; + switch (toolset) { + case "default": + return "Claude"; + case "codex": + return "Codex"; + case "codex_snake": + return "Codex (snake_case)"; + case "gemini": + return "Gemini"; + case "gemini_snake": + return "Gemini (snake_case)"; + case "none": + return "None"; + default: + return toolset; + } +} + interface ToolsetSelectorProps { - currentToolset?: ToolsetId; - onSelect: (toolsetId: ToolsetId) => void; + currentToolset?: ToolsetName; + currentPreference?: ToolsetPreference; + onSelect: (toolsetId: ToolsetPreference) => void; onCancel: () => void; } export function ToolsetSelector({ currentToolset, + currentPreference = "auto", onSelect, onCancel, }: ToolsetSelectorProps) { @@ -132,13 +105,16 @@ export function ToolsetSelector({ const visibleToolsets = useMemo(() => { if (showAll) return toolsets; if (featuredToolsets.length > 0) return featuredToolsets; - return toolsets.slice(0, 3); + return toolsets; }, [featuredToolsets, showAll]); - const hasHiddenToolsets = visibleToolsets.length < toolsets.length; - const hasShowAllOption = !showAll && hasHiddenToolsets; + const canToggleShowAll = featuredToolsets.length < toolsets.length; - const totalItems = visibleToolsets.length + (hasShowAllOption ? 1 : 0); + useEffect(() => { + if (selectedIndex >= visibleToolsets.length) { + setSelectedIndex(Math.max(0, visibleToolsets.length - 1)); + } + }, [selectedIndex, visibleToolsets.length]); useInput((input, key) => { // CTRL-C: immediately cancel @@ -150,17 +126,16 @@ export function ToolsetSelector({ if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1)); + setSelectedIndex((prev) => + Math.min(visibleToolsets.length - 1, prev + 1), + ); } else if (key.return) { - if (hasShowAllOption && selectedIndex === visibleToolsets.length) { - setShowAll(true); - setSelectedIndex(0); - } else { - const selectedToolset = visibleToolsets[selectedIndex]; - if (selectedToolset) { - onSelect(selectedToolset.id); - } + const selectedToolset = visibleToolsets[selectedIndex]; + if (selectedToolset) { + onSelect(selectedToolset.id); } + } else if (canToggleShowAll && (input === "a" || input === "A")) { + setShowAll((prev) => !prev); } else if (key.escape) { onCancel(); } @@ -184,56 +159,43 @@ export function ToolsetSelector({ {visibleToolsets.map((toolset, index) => { const isSelected = index === selectedIndex; - const isCurrent = toolset.id === currentToolset; + const isCurrent = toolset.id === currentPreference; + + const labelText = + toolset.id === "auto" + ? isCurrent + ? `Auto (current - ${formatEffectiveToolset(currentToolset)})` + : "Auto" + : isCurrent + ? `${toolset.label} (current)` + : toolset.label; return ( - - - - {isSelected ? "> " : " "} - - - {toolset.label} - {isCurrent && ( - (current) - )} - - - - {" "} - {toolset.description} + + + {isSelected ? "> " : " "} + + {labelText} + + {` · ${toolset.description}`} ); })} - {hasShowAllOption && ( - - - {selectedIndex === visibleToolsets.length ? "> " : " "} - - Show all toolsets - - )} {/* Footer */} - {" "}Enter select · ↑↓ navigate · Esc cancel + + {canToggleShowAll + ? " Enter select · ↑↓ navigate · A show all · Esc cancel" + : " Enter select · ↑↓ navigate · Esc cancel"} + ); diff --git a/src/index.ts b/src/index.ts index 390543a..8a0439a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ OPTIONS -n, --name Resume agent by name (from pinned agents, case-insensitive) -m, --model Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5") -s, --system System prompt ID or subagent name (applies to new or existing agent) - --toolset Force toolset: "codex", "default", or "gemini" (overrides model-based auto-selection) + --toolset Toolset mode: "auto", "codex", "default", or "gemini" (manual values override model-based auto-selection) -p, --prompt Headless prompt mode --output-format Output format for headless mode (text, json, stream-json) Default: text @@ -252,7 +252,7 @@ async function printInfo() { */ function getModelForToolLoading( specifiedModel?: string, - specifiedToolset?: "codex" | "default" | "gemini", + specifiedToolset?: "auto" | "codex" | "default" | "gemini", ): string | undefined { // If toolset is explicitly specified, use a dummy model from that provider // to trigger the correct toolset loading logic @@ -658,10 +658,11 @@ async function main(): Promise { specifiedToolset && specifiedToolset !== "codex" && specifiedToolset !== "default" && - specifiedToolset !== "gemini" + specifiedToolset !== "gemini" && + specifiedToolset !== "auto" ) { console.error( - `Error: Invalid toolset "${specifiedToolset}". Must be "codex", "default", or "gemini".`, + `Error: Invalid toolset "${specifiedToolset}". Must be "auto", "codex", "default", or "gemini".`, ); process.exit(1); } @@ -985,7 +986,7 @@ async function main(): Promise { // For headless mode, load tools synchronously (respecting model/toolset when provided) const modelForTools = getModelForToolLoading( specifiedModel, - specifiedToolset as "codex" | "default" | undefined, + specifiedToolset as "auto" | "codex" | "default" | "gemini" | undefined, ); await loadTools(modelForTools); markMilestone("TOOLS_LOADED"); @@ -1042,7 +1043,7 @@ async function main(): Promise { agentIdArg: string | null; model?: string; systemPromptPreset?: string; - toolset?: "codex" | "default" | "gemini"; + toolset?: "auto" | "codex" | "default" | "gemini"; skillsDirectory?: string; fromAfFile?: string; isRegistryImport?: boolean; @@ -1540,12 +1541,11 @@ async function main(): Promise { // Set resuming state early so loading messages are accurate setIsResumingSession(!!resumingAgentId); - // Load toolset: use explicit --toolset flag if provided, otherwise derive from model - // NOTE: We don't persist toolset per-agent. On resume, toolset is re-derived from model. - // If explicit toolset overrides need to persist, see comment in tools/toolset.ts + // Load an initial toolset for startup (explicit --toolset or model-derived). + // App.tsx will reconcile persisted per-agent toolset preference after agent metadata loads. const modelForTools = getModelForToolLoading( model, - toolset as "codex" | "default" | undefined, + toolset as "auto" | "codex" | "default" | "gemini" | undefined, ); await loadTools(modelForTools); @@ -2136,7 +2136,12 @@ async function main(): Promise { agentIdArg: specifiedAgentId, model: specifiedModel, systemPromptPreset: systemPromptPreset, - toolset: specifiedToolset as "codex" | "default" | "gemini" | undefined, + toolset: specifiedToolset as + | "auto" + | "codex" + | "default" + | "gemini" + | undefined, skillsDirectory: skillsDirectory, fromAfFile: fromAfFile, isRegistryImport: isRegistryImport, diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 0805a5f..5c38898 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -47,6 +47,14 @@ export interface AgentSettings { baseUrl?: string; // undefined = Letta API (api.letta.com) pinned?: boolean; // true if agent is pinned memfs?: boolean; // true if memory filesystem is enabled + toolset?: + | "auto" + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none"; // toolset mode for this agent (manual override or auto) } export interface Settings { @@ -1317,10 +1325,15 @@ class SettingsManager { pinned: updates.pinned !== undefined ? updates.pinned : existing.pinned, // Use nullish coalescing for memfs (undefined = keep existing) memfs: updates.memfs !== undefined ? updates.memfs : existing.memfs, + // Use nullish coalescing for toolset (undefined = keep existing) + toolset: + updates.toolset !== undefined ? updates.toolset : existing.toolset, }; // Clean up undefined/false values if (!updated.pinned) delete updated.pinned; if (!updated.memfs) delete updated.memfs; + if (!updated.toolset || updated.toolset === "auto") + delete updated.toolset; if (!updated.baseUrl) delete updated.baseUrl; agents[idx] = updated; } else { @@ -1333,6 +1346,8 @@ class SettingsManager { // Clean up undefined/false values if (!newAgent.pinned) delete newAgent.pinned; if (!newAgent.memfs) delete newAgent.memfs; + if (!newAgent.toolset || newAgent.toolset === "auto") + delete newAgent.toolset; if (!newAgent.baseUrl) delete newAgent.baseUrl; agents.push(newAgent); } @@ -1354,6 +1369,40 @@ class SettingsManager { this.upsertAgentSettings(agentId, { memfs: enabled }); } + /** + * Get toolset preference for an agent on the current server. + * Defaults to "auto" when no manual override is stored. + */ + getToolsetPreference( + agentId: string, + ): + | "auto" + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none" { + return this.getAgentSettings(agentId)?.toolset ?? "auto"; + } + + /** + * Set toolset preference for an agent on the current server. + */ + setToolsetPreference( + agentId: string, + preference: + | "auto" + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none", + ): void { + this.upsertAgentSettings(agentId, { toolset: preference }); + } + /** * Check if local .letta directory exists (indicates existing project) */ diff --git a/src/tests/settings-manager.test.ts b/src/tests/settings-manager.test.ts index c9eb9ff..115c85a 100644 --- a/src/tests/settings-manager.test.ts +++ b/src/tests/settings-manager.test.ts @@ -977,3 +977,37 @@ describe("Settings Manager - Agents Array Migration", () => { expect(settingsManager.isMemfsEnabled("agent-persist-test")).toBe(true); }); }); + +describe("Settings Manager - Toolset Preferences", () => { + test("getToolsetPreference defaults to auto", async () => { + await settingsManager.initialize(); + + expect(settingsManager.getToolsetPreference("agent-unset")).toBe("auto"); + }); + + test("setToolsetPreference stores and clears manual override", async () => { + await settingsManager.initialize(); + + settingsManager.setToolsetPreference("agent-toolset", "codex"); + expect(settingsManager.getToolsetPreference("agent-toolset")).toBe("codex"); + + settingsManager.setToolsetPreference("agent-toolset", "auto"); + expect(settingsManager.getToolsetPreference("agent-toolset")).toBe("auto"); + }); + + test("setToolsetPreference persists to disk", async () => { + await settingsManager.initialize(); + + settingsManager.setToolsetPreference("agent-toolset-persist", "gemini"); + + // Wait for async persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + await settingsManager.reset(); + await settingsManager.initialize(); + + expect(settingsManager.getToolsetPreference("agent-toolset-persist")).toBe( + "gemini", + ); + }); +}); diff --git a/src/tests/startup-flow.test.ts b/src/tests/startup-flow.test.ts index 729c31d..367b024 100644 --- a/src/tests/startup-flow.test.ts +++ b/src/tests/startup-flow.test.ts @@ -140,4 +140,15 @@ describe("Startup Flow - Smoke", () => { expect(result.stderr).toContain("Missing LETTA_API_KEY"); expect(result.stderr).not.toContain("No recent session found"); }); + + test("--toolset auto is accepted", async () => { + const result = await runCli( + ["--new-agent", "--toolset", "auto", "-p", "Say OK"], + { + expectExit: 1, + }, + ); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Invalid toolset"); + }); }); diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts index 1cc044e..aa49585 100644 --- a/src/tools/toolset.ts +++ b/src/tools/toolset.ts @@ -5,6 +5,7 @@ import { clearToolsWithLock, GEMINI_PASCAL_TOOLS, getToolNames, + isGeminiModel, isOpenAIModel, loadSpecificTools, loadTools, @@ -35,6 +36,18 @@ export type ToolsetName = | "gemini" | "gemini_snake" | "none"; +export type ToolsetPreference = ToolsetName | "auto"; + +export function deriveToolsetFromModel( + modelIdentifier: string, +): "codex" | "gemini" | "default" { + const resolvedModel = resolveModel(modelIdentifier) ?? modelIdentifier; + return isOpenAIModel(resolvedModel) + ? "codex" + : isGeminiModel(resolvedModel) + ? "gemini" + : "default"; +} /** * Ensures the correct memory tool is attached to the agent based on the model. @@ -248,11 +261,6 @@ export async function forceToolsetSwitch( const useMemoryPatch = toolsetName === "codex" || toolsetName === "codex_snake"; await ensureCorrectMemoryTool(agentId, modelForLoading, useMemoryPatch); - - // NOTE: Toolset is not persisted. On resume, we derive from agent's model. - // If we want to persist explicit toolset overrides in the future, add: - // agentToolsets: Record to Settings (global, since agent IDs are UUIDs) - // and save here: settingsManager.updateSettings({ agentToolsets: { ...current, [agentId]: toolsetName } }) } /** @@ -293,13 +301,6 @@ export async function switchToolsetForModel( // Ensure base memory tool is correct for the model await ensureCorrectMemoryTool(agentId, resolvedModel); - const { isGeminiModel } = await import("./manager"); - const toolsetName = isOpenAIModel(resolvedModel) - ? "codex" - : isGeminiModel(resolvedModel) - ? "gemini" - : "default"; - - // NOTE: Toolset is derived from model, not persisted. See comment in forceToolsetSwitch. + const toolsetName = deriveToolsetFromModel(resolvedModel); return toolsetName; }