feat: persist toolset mode and add auto option (#1015)
This commit is contained in:
187
src/cli/App.tsx
187
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<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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user