// src/agent/modify.ts // Utilities for modifying agent configuration import type { AgentState, AnthropicModelSettings, GoogleAIModelSettings, OpenAIModelSettings, } from "@letta-ai/letta-client/resources/agents/agents"; import type { Conversation } from "@letta-ai/letta-client/resources/conversations/conversations"; import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider"; import { getModelContextWindow } from "./available-models"; import { getClient } from "./client"; type ModelSettings = | OpenAIModelSettings | AnthropicModelSettings | GoogleAIModelSettings | Record; /** * Builds model_settings from updateArgs based on provider type. * Always ensures parallel_tool_calls is enabled. */ function buildModelSettings( modelHandle: string, updateArgs?: Record, ): ModelSettings { // Include our custom ChatGPT OAuth provider (chatgpt-plus-pro) const isOpenAI = modelHandle.startsWith("openai/") || modelHandle.startsWith(`${OPENAI_CODEX_PROVIDER_NAME}/`); // Include legacy custom Anthropic OAuth provider (claude-pro-max) and minimax const isAnthropic = modelHandle.startsWith("anthropic/") || modelHandle.startsWith("claude-pro-max/") || modelHandle.startsWith("minimax/"); const isZai = modelHandle.startsWith("zai/"); const isGoogleAI = modelHandle.startsWith("google_ai/"); const isGoogleVertex = modelHandle.startsWith("google_vertex/"); const isOpenRouter = modelHandle.startsWith("openrouter/"); const isBedrock = modelHandle.startsWith("bedrock/"); let settings: ModelSettings; if (isOpenAI || isOpenRouter) { const openaiSettings: OpenAIModelSettings = { provider_type: "openai", parallel_tool_calls: true, }; if (updateArgs?.reasoning_effort) { openaiSettings.reasoning = { reasoning_effort: updateArgs.reasoning_effort as | "none" | "minimal" | "low" | "medium" | "high" | "xhigh", }; } const verbosity = updateArgs?.verbosity; if (verbosity === "low" || verbosity === "medium" || verbosity === "high") { // The backend supports verbosity for OpenAI-family providers; the generated // client type may lag this field, so set it via a narrow record cast. (openaiSettings as Record).verbosity = verbosity; } if (typeof updateArgs?.strict === "boolean") { openaiSettings.strict = updateArgs.strict; } settings = openaiSettings; } else if (isAnthropic) { const anthropicSettings: AnthropicModelSettings = { provider_type: "anthropic", parallel_tool_calls: true, }; // Map reasoning_effort to Anthropic's effort field (controls token spending via output_config) const effort = updateArgs?.reasoning_effort; if (effort === "low" || effort === "medium" || effort === "high") { anthropicSettings.effort = effort; } else if (effort === "xhigh") { // "max" is valid on the backend but the SDK type hasn't caught up yet (anthropicSettings as Record).effort = "max"; } // Build thinking config if either enable_reasoner or max_reasoning_tokens is specified if ( updateArgs?.enable_reasoner !== undefined || typeof updateArgs?.max_reasoning_tokens === "number" ) { anthropicSettings.thinking = { type: updateArgs?.enable_reasoner === false ? "disabled" : "enabled", ...(typeof updateArgs?.max_reasoning_tokens === "number" && { budget_tokens: updateArgs.max_reasoning_tokens, }), }; } if (typeof updateArgs?.strict === "boolean") { (anthropicSettings as Record).strict = updateArgs.strict; } settings = anthropicSettings; } else if (isZai) { // Zai uses the same model_settings structure as other providers. // Ensure parallel_tool_calls is enabled. settings = { provider_type: "zai", parallel_tool_calls: true, }; } else if (isGoogleAI) { const googleSettings: GoogleAIModelSettings & { temperature?: number } = { provider_type: "google_ai", parallel_tool_calls: true, }; if (updateArgs?.thinking_budget !== undefined) { googleSettings.thinking_config = { thinking_budget: updateArgs.thinking_budget as number, }; } if (typeof updateArgs?.temperature === "number") { googleSettings.temperature = updateArgs.temperature as number; } settings = googleSettings; } else if (isGoogleVertex) { // Vertex AI uses the same Google provider on the backend; only the handle differs. const googleVertexSettings: Record = { provider_type: "google_vertex", parallel_tool_calls: true, }; if (updateArgs?.thinking_budget !== undefined) { (googleVertexSettings as Record).thinking_config = { thinking_budget: updateArgs.thinking_budget as number, }; } if (typeof updateArgs?.temperature === "number") { (googleVertexSettings as Record).temperature = updateArgs.temperature as number; } settings = googleVertexSettings; } else if (isBedrock) { // AWS Bedrock - supports Anthropic Claude models with thinking config const bedrockSettings: Record = { provider_type: "bedrock", parallel_tool_calls: true, }; // Map reasoning_effort to Anthropic's effort field (Bedrock runs Claude models) const effort = updateArgs?.reasoning_effort; if (effort === "low" || effort === "medium" || effort === "high") { bedrockSettings.effort = effort; } else if (effort === "xhigh") { bedrockSettings.effort = "max"; } // Build thinking config if either enable_reasoner or max_reasoning_tokens is specified if ( updateArgs?.enable_reasoner !== undefined || typeof updateArgs?.max_reasoning_tokens === "number" ) { bedrockSettings.thinking = { type: updateArgs?.enable_reasoner === false ? "disabled" : "enabled", ...(typeof updateArgs?.max_reasoning_tokens === "number" && { budget_tokens: updateArgs.max_reasoning_tokens, }), }; } settings = bedrockSettings; } else { // Unknown/BYOK providers (e.g. openai-proxy) — assume OpenAI-compatible const openaiProxySettings: OpenAIModelSettings = { provider_type: "openai", parallel_tool_calls: typeof updateArgs?.parallel_tool_calls === "boolean" ? updateArgs.parallel_tool_calls : true, }; if (typeof updateArgs?.strict === "boolean") { (openaiProxySettings as Record).strict = updateArgs.strict; } settings = openaiProxySettings; } // Apply max_output_tokens only when provider_type is present and the value // is a concrete number. Null means "unset" and should only be forwarded via // the top-level max_tokens field — some providers (e.g. OpenAI) reject null // inside their typed model_settings. if ( typeof updateArgs?.max_output_tokens === "number" && "provider_type" in settings ) { (settings as Record).max_output_tokens = updateArgs.max_output_tokens; } return settings; } /** * Updates an agent's model and model settings. * * Uses the new model_settings field instead of deprecated llm_config. * * @param agentId - The agent ID * @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 options - Optional update behavior overrides * @returns The updated agent state from the server (includes llm_config and model_settings) */ export interface UpdateAgentLLMConfigOptions { preserveContextWindow?: boolean; } export async function updateAgentLLMConfig( agentId: string, modelHandle: string, updateArgs?: Record, options?: UpdateAgentLLMConfigOptions, ): Promise { const client = await getClient(); const modelSettings = buildModelSettings(modelHandle, updateArgs); const explicitContextWindow = updateArgs?.context_window as | number | undefined; const shouldPreserveContextWindow = options?.preserveContextWindow === true; // Resume refresh updates should not implicitly reset context window. const contextWindow = explicitContextWindow ?? (!shouldPreserveContextWindow ? await getModelContextWindow(modelHandle) : undefined); const hasModelSettings = Object.keys(modelSettings).length > 0; await client.agents.update(agentId, { model: modelHandle, ...(hasModelSettings && { model_settings: modelSettings }), ...(contextWindow && { context_window_limit: contextWindow }), ...((typeof updateArgs?.max_output_tokens === "number" || updateArgs?.max_output_tokens === null) && { max_tokens: updateArgs.max_output_tokens, }), }); const finalAgent = await client.agents.retrieve(agentId); return finalAgent; } /** * Updates a conversation's model and model settings. * * Uses conversation-scoped model overrides so different conversations can * run with different models without mutating the agent's default model. * * @param conversationId - The conversation ID (or "default") * @param modelHandle - The model handle (e.g., "anthropic/claude-sonnet-4-5-20250929") * @param updateArgs - Additional config args (reasoning_effort, enable_reasoner, etc.) * @returns The updated conversation from the server */ export async function updateConversationLLMConfig( conversationId: string, modelHandle: string, updateArgs?: Record, ): Promise { const client = await getClient(); const modelSettings = buildModelSettings(modelHandle, updateArgs); const hasModelSettings = Object.keys(modelSettings).length > 0; const payload = { model: modelHandle, ...(hasModelSettings && { model_settings: modelSettings }), } as unknown as Parameters[1]; return client.conversations.update(conversationId, payload); } /** * Recompile an agent's system prompt after memory writes so server-side prompt * state picks up the latest memory content. * * @param conversationId - The conversation whose prompt should be recompiled * @param agentId - Agent id for the parent conversation * @param dryRun - Optional dry-run control * @param clientOverride - Optional injected client for tests * @returns The compiled system prompt returned by the API */ export async function recompileAgentSystemPrompt( conversationId: string, agentId: string, dryRun?: boolean, clientOverride?: { conversations: { recompile: ( conversationId: string, params: { dry_run?: boolean; agent_id?: string; }, ) => Promise; }; }, ): Promise { const client = (clientOverride ?? (await getClient())) as Exclude< typeof clientOverride, undefined >; if (!agentId) { throw new Error("recompileAgentSystemPrompt requires agentId"); } const params = { dry_run: dryRun, agent_id: agentId, }; return client.conversations.recompile(conversationId, params); } export interface SystemPromptUpdateResult { success: boolean; message: string; } /** * Updates an agent's system prompt with raw content. * * @param agentId - The agent ID * @param systemPromptContent - The raw system prompt content to update * @returns Result with success status and message */ export async function updateAgentSystemPromptRaw( agentId: string, systemPromptContent: string, ): Promise { try { const client = await getClient(); await client.agents.update(agentId, { system: systemPromptContent, }); return { success: true, message: "System prompt updated successfully", }; } catch (error) { return { success: false, message: `Failed to update system prompt: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Result from updating a system prompt on an agent */ export interface UpdateSystemPromptResult { success: boolean; message: string; agent: AgentState | null; } /** * Updates an agent's system prompt by ID or subagent name. * Resolves the ID to content, updates the agent, and returns the refreshed agent state. * * @param agentId - The agent ID to update * @param systemPromptId - System prompt ID (e.g., "codex") or subagent name (e.g., "explore") * @returns Result with success status, message, and updated agent state */ export async function updateAgentSystemPrompt( agentId: string, systemPromptId: string, ): Promise { try { const { isKnownPreset, resolveAndBuildSystemPrompt } = await import( "./promptAssets" ); const { settingsManager } = await import("../settings-manager"); const client = await getClient(); const memoryMode = settingsManager.isReady && settingsManager.isMemfsEnabled(agentId) ? "memfs" : "standard"; const systemPromptContent = await resolveAndBuildSystemPrompt( systemPromptId, memoryMode, ); console.debug("[modify] systemPromptContent:", systemPromptContent); const updateResult = await updateAgentSystemPromptRaw( agentId, systemPromptContent, ); if (!updateResult.success) { return { success: false, message: updateResult.message, agent: null, }; } // Persist preset for known presets; clear stale preset for subagent/unknown if (settingsManager.isReady) { if (isKnownPreset(systemPromptId)) { settingsManager.setSystemPromptPreset(agentId, systemPromptId); } else { settingsManager.clearSystemPromptPreset(agentId); } } // Re-fetch agent to get updated state const agent = await client.agents.retrieve(agentId); return { success: true, message: "System prompt applied successfully", agent, }; } catch (error) { return { success: false, message: `Failed to apply system prompt: ${error instanceof Error ? error.message : String(error)}`, agent: null, }; } } /** * Updates an agent's system prompt to swap between managed memory modes. * * Uses the shared memory prompt reconciler so we safely replace managed memory * sections without corrupting fenced code blocks or leaving orphan fragments. * * @param agentId - The agent ID to update * @param enableMemfs - Whether to enable (add) or disable (remove) the memfs addon * @returns Result with success status and message */ export async function updateAgentSystemPromptMemfs( agentId: string, enableMemfs: boolean, ): Promise { try { const { settingsManager } = await import("../settings-manager"); const { isKnownPreset, buildSystemPrompt, swapMemoryAddon } = await import( "./promptAssets" ); const newMode = enableMemfs ? "memfs" : "standard"; const storedPreset = settingsManager.isReady ? settingsManager.getSystemPromptPreset(agentId) : undefined; let nextSystemPrompt: string; if (storedPreset && isKnownPreset(storedPreset)) { nextSystemPrompt = buildSystemPrompt(storedPreset, newMode); } else { const client = await getClient(); const agent = await client.agents.retrieve(agentId); nextSystemPrompt = swapMemoryAddon(agent.system || "", newMode); } const client = await getClient(); await client.agents.update(agentId, { system: nextSystemPrompt, }); return { success: true, message: enableMemfs ? "System prompt updated to include Memory Filesystem section" : "System prompt updated to include standard Memory section", }; } catch (error) { return { success: false, message: `Failed to update system prompt memfs: ${error instanceof Error ? error.message : String(error)}`, }; } }