feat: persist toolset mode and add auto option (#1015)

This commit is contained in:
Charles Packer
2026-02-18 13:43:40 -08:00
committed by GitHub
parent bdc23932b5
commit 5fac4bc106
7 changed files with 333 additions and 230 deletions

View File

@@ -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<ToolsetName | null>(
null,
);
const [currentToolsetPreference, setCurrentToolsetPreference] =
useState<ToolsetPreference>("auto");
const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null);
const llmConfigRef = useRef(llmConfig);
useEffect(() => {
@@ -1279,7 +1268,7 @@ export default function App({
}, [llmConfig]);
const [currentModelId, setCurrentModelId] = useState<string | null>(null);
// Full model handle for API calls (e.g., "anthropic/claude-sonnet-4-5-20251101")
const [_currentModelHandle, setCurrentModelHandle] = useState<string | null>(
const [currentModelHandle, setCurrentModelHandle] = useState<string | null>(
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" && (
<ToolsetSelector
currentToolset={currentToolset ?? undefined}
currentPreference={currentToolsetPreference}
onSelect={handleToolsetSelect}
onCancel={closeOverlay}
/>

View File

@@ -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({
<Box flexDirection="column">
{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 (
<Box key={toolset.id} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "> " : " "}
</Text>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{toolset.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
</Box>
<Text dimColor>
{" "}
{toolset.description}
<Box key={toolset.id} flexDirection="row">
<Text
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{isSelected ? "> " : " "}
</Text>
<Text
bold={isSelected}
color={isSelected ? colors.selector.itemHighlighted : undefined}
>
{labelText}
</Text>
<Text dimColor>{` · ${toolset.description}`}</Text>
</Box>
);
})}
{hasShowAllOption && (
<Box flexDirection="row">
<Text
color={
selectedIndex === visibleToolsets.length
? colors.selector.itemHighlighted
: undefined
}
>
{selectedIndex === visibleToolsets.length ? "> " : " "}
</Text>
<Text dimColor>Show all toolsets</Text>
</Box>
)}
</Box>
{/* Footer */}
<Box marginTop={1}>
<Text dimColor>{" "}Enter select · navigate · Esc cancel</Text>
<Text dimColor>
{canToggleShowAll
? " Enter select · ↑↓ navigate · A show all · Esc cancel"
: " Enter select · ↑↓ navigate · Esc cancel"}
</Text>
</Box>
</Box>
);