fix(tui): footer reasoning tier, toolset naming, and selector highlight (#1024)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-18 18:48:25 -08:00
committed by GitHub
parent b871ff793d
commit 35291d9094
5 changed files with 197 additions and 90 deletions

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@letta-ai/letta-code",

View File

@@ -7,7 +7,6 @@ import type {
GoogleAIModelSettings,
OpenAIModelSettings,
} from "@letta-ai/letta-client/resources/agents/agents";
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider";
import { getModelContextWindow } from "./available-models";
import { getClient } from "./client";
@@ -181,13 +180,13 @@ function buildModelSettings(
* @param modelHandle - The model handle (e.g., "anthropic/claude-sonnet-4-5-20250929")
* @param updateArgs - Additional config args (context_window, reasoning_effort, enable_reasoner, etc.)
* @param preserveParallelToolCalls - If true, preserves the parallel_tool_calls setting when updating the model
* @returns The updated LLM configuration from the server
* @returns The updated agent state from the server (includes llm_config and model_settings)
*/
export async function updateAgentLLMConfig(
agentId: string,
modelHandle: string,
updateArgs?: Record<string, unknown>,
): Promise<LlmConfig> {
): Promise<AgentState> {
const client = await getClient();
const modelSettings = buildModelSettings(modelHandle, updateArgs);
@@ -207,7 +206,7 @@ export async function updateAgentLLMConfig(
});
const finalAgent = await client.agents.retrieve(agentId);
return finalAgent.llm_config;
return finalAgent;
}
export interface SystemPromptUpdateResult {

View File

@@ -103,6 +103,7 @@ import {
type ToolExecutionResult,
} from "../tools/manager";
import type { ToolsetName, ToolsetPreference } from "../tools/toolset";
import { formatToolsetName } from "../tools/toolset-labels";
import { debugLog, debugWarn } from "../utils/debug";
import {
handleMcpAdd,
@@ -315,6 +316,63 @@ const OPUS_BEDROCK_FALLBACK_HINT =
const PROVIDER_FALLBACK_HINT =
"Downstream provider issues? Use /model to switch to another provider";
/**
* Derives the current reasoning effort from agent state (canonical) with llm_config as fallback.
* model_settings is the source of truth; llm_config.reasoning_effort is a legacy field.
*/
function deriveReasoningEffort(
modelSettings: AgentState["model_settings"] | undefined | null,
llmConfig: LlmConfig | null | undefined,
): ModelReasoningEffort | null {
if (modelSettings && "provider_type" in modelSettings) {
// OpenAI/OpenRouter: reasoning.reasoning_effort
if (
modelSettings.provider_type === "openai" &&
"reasoning" in modelSettings &&
modelSettings.reasoning
) {
const re = (modelSettings.reasoning as { reasoning_effort?: string })
.reasoning_effort;
if (
re === "none" ||
re === "minimal" ||
re === "low" ||
re === "medium" ||
re === "high" ||
re === "xhigh"
)
return re;
}
// Anthropic/Bedrock: effort field
if (
modelSettings.provider_type === "anthropic" ||
modelSettings.provider_type === "bedrock"
) {
const effort = (modelSettings as { effort?: string | null }).effort;
if (effort === "low" || effort === "medium" || effort === "high")
return effort;
if (effort === "max") return "xhigh";
}
}
// Fallback: deprecated llm_config fields
const re = llmConfig?.reasoning_effort;
if (
re === "none" ||
re === "minimal" ||
re === "low" ||
re === "medium" ||
re === "high" ||
re === "xhigh"
)
return re;
if (
(llmConfig as { enable_reasoner?: boolean | null })?.enable_reasoner ===
false
)
return "none";
return null;
}
// Helper to get appropriate error hint based on stop reason and current model
function getErrorHintForStopReason(
stopReason: StopReasonType | null,
@@ -1284,6 +1342,10 @@ export default function App({
useEffect(() => {
llmConfigRef.current = llmConfig;
}, [llmConfig]);
const agentStateRef = useRef(agentState);
useEffect(() => {
agentStateRef.current = agentState;
}, [agentState]);
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>(
@@ -1302,50 +1364,9 @@ export default function App({
currentModelLabel.split("/").pop())
: null;
const currentModelProvider = llmConfig?.provider_name ?? null;
// Derive reasoning effort from model_settings (preferred over deprecated llm_config)
const currentReasoningEffort: ModelReasoningEffort | null = (() => {
const ms = agentState?.model_settings;
if (ms && "provider_type" in ms) {
// OpenAI/OpenRouter: reasoning.reasoning_effort
if (ms.provider_type === "openai" && "reasoning" in ms && ms.reasoning) {
const re = (ms.reasoning as { reasoning_effort?: string })
.reasoning_effort;
if (
re === "none" ||
re === "minimal" ||
re === "low" ||
re === "medium" ||
re === "high" ||
re === "xhigh"
)
return re;
}
// Anthropic/Bedrock: effort field (maps to output_config.effort in the API)
if (ms.provider_type === "anthropic" || ms.provider_type === "bedrock") {
const effort = (ms as { effort?: string | null }).effort;
if (effort === "low" || effort === "medium" || effort === "high")
return effort;
if (effort === "max") return "xhigh";
}
}
// Fallback: deprecated llm_config fields
const re = llmConfig?.reasoning_effort;
if (
re === "none" ||
re === "minimal" ||
re === "low" ||
re === "medium" ||
re === "high" ||
re === "xhigh"
)
return re;
if (
(llmConfig as { enable_reasoner?: boolean | null })?.enable_reasoner ===
false
)
return "none";
return null;
})();
// Derive reasoning effort from model_settings (canonical) with llm_config as legacy fallback
const currentReasoningEffort: ModelReasoningEffort | null =
deriveReasoningEffort(agentState?.model_settings, llmConfig);
// Billing tier for conditional UI and error context (fetched once on mount)
const [billingTier, setBillingTier] = useState<string | null>(null);
@@ -5202,6 +5223,9 @@ export default function App({
modelId: string;
} | null>(null);
const reasoningCycleLastConfirmedRef = useRef<LlmConfig | null>(null);
const reasoningCycleLastConfirmedAgentStateRef = useRef<AgentState | null>(
null,
);
const resetPendingReasoningCycle = useCallback(() => {
if (reasoningCycleTimerRef.current) {
@@ -5210,6 +5234,7 @@ export default function App({
}
reasoningCycleDesiredRef.current = null;
reasoningCycleLastConfirmedRef.current = null;
reasoningCycleLastConfirmedAgentStateRef.current = null;
}, []);
const handleAgentSelect = useCallback(
@@ -10064,12 +10089,22 @@ ${SYSTEM_REMINDER_CLOSE}
});
const { updateAgentLLMConfig } = await import("../agent/modify");
const updatedConfig = await updateAgentLLMConfig(
const updatedAgent = await updateAgentLLMConfig(
agentId,
modelHandle,
model.updateArgs,
);
setLlmConfig(updatedConfig);
setLlmConfig(updatedAgent.llm_config);
// Refresh agentState so model_settings (canonical reasoning effort source) is current
setAgentState((prev) =>
prev
? {
...prev,
llm_config: updatedAgent.llm_config,
model_settings: updatedAgent.model_settings,
}
: updatedAgent,
);
setCurrentModelId(modelId);
// Reset context token tracking since different models have different tokenizers
@@ -10090,9 +10125,11 @@ ${SYSTEM_REMINDER_CLOSE}
);
setCurrentToolsetPreference("auto");
setCurrentToolset(toolsetName);
// Only notify when the toolset actually changes (e.g., Claude → Codex)
if (toolsetName !== currentToolset) {
toolsetNoticeLine =
"Auto toolset selected: switched to " +
toolsetName +
formatToolsetName(toolsetName) +
". Use /toolset to set a manual override.";
maybeRecordToolsetChangeReminder({
source: "/model (auto toolset)",
@@ -10101,6 +10138,7 @@ ${SYSTEM_REMINDER_CLOSE}
previousTools: previousToolNamesSnapshot,
newTools: getToolNames(),
});
}
} else {
const { forceToolsetSwitch } = await import("../tools/toolset");
if (currentToolset !== persistedToolsetPreference) {
@@ -10117,7 +10155,7 @@ ${SYSTEM_REMINDER_CLOSE}
setCurrentToolsetPreference(persistedToolsetPreference);
toolsetNoticeLine =
"Manual toolset override remains active: " +
persistedToolsetPreference +
formatToolsetName(persistedToolsetPreference) +
".";
}
@@ -10388,7 +10426,7 @@ ${SYSTEM_REMINDER_CLOSE}
newTools: getToolNames(),
});
cmd.finish(
`Toolset mode set to auto (currently ${derivedToolset}).`,
`Toolset mode set to auto (currently ${formatToolsetName(derivedToolset)}).`,
true,
);
return;
@@ -10406,7 +10444,7 @@ ${SYSTEM_REMINDER_CLOSE}
newTools: getToolNames(),
});
cmd.finish(
`Switched toolset to ${toolsetId} (manual override)`,
`Switched toolset to ${formatToolsetName(toolsetId)} (manual override)`,
true,
);
} catch (error) {
@@ -10731,7 +10769,7 @@ ${SYSTEM_REMINDER_CLOSE}
try {
const { updateAgentLLMConfig } = await import("../agent/modify");
const updated = await updateAgentLLMConfig(
const updatedAgent = await updateAgentLLMConfig(
agentId,
desired.modelHandle,
{
@@ -10739,12 +10777,23 @@ ${SYSTEM_REMINDER_CLOSE}
},
);
setLlmConfig(updated);
setLlmConfig(updatedAgent.llm_config);
// Refresh agentState so model_settings (canonical reasoning effort source) is current
setAgentState((prev) =>
prev
? {
...prev,
llm_config: updatedAgent.llm_config,
model_settings: updatedAgent.model_settings,
}
: updatedAgent,
);
setCurrentModelId(desired.modelId);
// Clear pending state.
reasoningCycleDesiredRef.current = null;
reasoningCycleLastConfirmedRef.current = null;
reasoningCycleLastConfirmedAgentStateRef.current = null;
const display =
desired.effort === "medium"
@@ -10763,6 +10812,11 @@ ${SYSTEM_REMINDER_CLOSE}
reasoningCycleDesiredRef.current = null;
reasoningCycleLastConfirmedRef.current = null;
setLlmConfig(prev);
// Also revert the agentState optimistic patch
if (reasoningCycleLastConfirmedAgentStateRef.current) {
setAgentState(reasoningCycleLastConfirmedAgentStateRef.current);
reasoningCycleLastConfirmedAgentStateRef.current = null;
}
const { getModelInfo } = await import("../agent/model");
const modelHandle =
@@ -10802,7 +10856,10 @@ ${SYSTEM_REMINDER_CLOSE}
: current?.model;
if (!modelHandle) return;
const currentEffort = current?.reasoning_effort ?? "none";
// Derive current effort from agentState.model_settings (canonical) with llmConfig fallback
const currentEffort =
deriveReasoningEffort(agentStateRef.current?.model_settings, current) ??
"none";
const { models } = await import("../agent/model");
const tiers = models
@@ -10836,12 +10893,55 @@ ${SYSTEM_REMINDER_CLOSE}
// Snapshot the last confirmed config once per burst so we can revert on failure.
if (!reasoningCycleLastConfirmedRef.current) {
reasoningCycleLastConfirmedRef.current = current ?? null;
reasoningCycleLastConfirmedAgentStateRef.current =
agentStateRef.current ?? null;
}
// Optimistic UI update (footer changes immediately).
setLlmConfig((prev) =>
prev ? ({ ...prev, reasoning_effort: next.effort } as LlmConfig) : prev,
);
// Also patch agentState.model_settings for OpenAI/Anthropic/Bedrock so the footer
// (which prefers model_settings) reflects the change without waiting for the server.
setAgentState((prev) => {
if (!prev) return prev ?? null;
const ms = prev.model_settings;
if (!ms || !("provider_type" in ms)) return prev;
if (ms.provider_type === "openai") {
return {
...prev,
model_settings: {
...ms,
reasoning: {
...(ms as { reasoning?: Record<string, unknown> }).reasoning,
reasoning_effort: next.effort as
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh",
},
},
} as AgentState;
}
if (
ms.provider_type === "anthropic" ||
ms.provider_type === "bedrock"
) {
// Map "xhigh" → "max": footer derivation only recognizes "max" for Anthropic effort.
// Cast needed: "max" is valid on the backend but not yet in the SDK type.
const anthropicEffort = next.effort === "xhigh" ? "max" : next.effort;
return {
...prev,
model_settings: {
...ms,
effort: anthropicEffort as "low" | "medium" | "high" | "max",
},
} as AgentState;
}
return prev;
});
setCurrentModelId(next.id);
// Debounce the server update.

View File

@@ -2,6 +2,7 @@
import { Box, useInput } from "ink";
import { useEffect, useMemo, useState } from "react";
import type { ToolsetName, ToolsetPreference } from "../../tools/toolset";
import { formatToolsetName } from "../../tools/toolset-labels";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { Text } from "./Text";
@@ -59,26 +60,6 @@ const toolsets: ToolsetOption[] = [
},
];
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?: ToolsetName;
currentPreference?: ToolsetPreference;
@@ -164,7 +145,7 @@ export function ToolsetSelector({
const labelText =
toolset.id === "auto"
? isCurrent
? `Auto (current - ${formatEffectiveToolset(currentToolset)})`
? `Auto (current - ${formatToolsetName(currentToolset)})`
: "Auto"
: isCurrent
? `${toolset.label} (current)`
@@ -179,7 +160,13 @@ export function ToolsetSelector({
</Text>
<Text
bold={isSelected}
color={isSelected ? colors.selector.itemHighlighted : undefined}
color={
isSelected
? colors.selector.itemHighlighted
: isCurrent
? colors.selector.itemCurrent
: undefined
}
>
{labelText}
</Text>

View File

@@ -0,0 +1,22 @@
/**
* Human-readable display names for toolset IDs.
* Kept in a separate file to avoid pulling UI formatting logic into the heavy toolset.ts module.
*/
export const TOOLSET_DISPLAY_NAMES: Record<string, string> = {
default: "Claude",
codex: "Codex",
codex_snake: "Codex (snake_case)",
gemini: "Gemini",
gemini_snake: "Gemini (snake_case)",
none: "None",
auto: "Auto",
};
/**
* Returns the human-readable display name for a toolset ID.
* id is optional to accommodate optional currentToolset props.
*/
export function formatToolsetName(id?: string): string {
if (!id) return "Unknown";
return TOOLSET_DISPLAY_NAMES[id] ?? id;
}