diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 267326a7e20..819143e6262 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -28,7 +28,7 @@ import type { VerboseLevel } from "../thinking.js"; import type { GetReplyOptions } from "../types.js"; import { buildEmbeddedRunExecutionParams, - resolveModelFallbackOptions, + resolveCompactionModelFallbackOptions, } from "./agent-runner-utils.js"; import { hasAlreadyFlushedForCurrentCompaction, @@ -282,13 +282,14 @@ export async function runMemoryFlushIfNeeded(params: { return sandboxCfg.workspaceAccess === "rw"; })(); - const isCli = isCliProvider(params.followupRun.run.provider, params.cfg); + const compactionFallbackOptions = resolveCompactionModelFallbackOptions(params.followupRun.run); + const isCli = isCliProvider(compactionFallbackOptions.provider, params.cfg); const canAttemptFlush = memoryFlushWritable && !params.isHeartbeat && !isCli; let entry = params.sessionEntry ?? (params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined); const contextWindowTokens = resolveMemoryFlushContextWindowTokens({ - modelId: params.followupRun.run.model ?? params.defaultModel, + modelId: compactionFallbackOptions.model ?? params.defaultModel, agentCfgContextTokens: params.agentCfgContextTokens, }); @@ -478,7 +479,7 @@ export async function runMemoryFlushIfNeeded(params: { .join("\n\n"); try { await runWithModelFallback({ - ...resolveModelFallbackOptions(params.followupRun.run), + ...compactionFallbackOptions, runId: flushRunId, run: async (provider, model, runOptions) => { const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({ diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 5bf77cd9f70..6cb343cd19a 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -1,20 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FollowupRun } from "./queue.js"; -const hoisted = vi.hoisted(() => { - const resolveRunModelFallbacksOverrideMock = vi.fn(); - return { resolveRunModelFallbacksOverrideMock }; -}); - -vi.mock("../../agents/agent-scope.js", () => ({ - resolveRunModelFallbacksOverride: (...args: unknown[]) => - hoisted.resolveRunModelFallbacksOverrideMock(...args), -})); - const { buildThreadingToolContext, buildEmbeddedRunBaseParams, buildEmbeddedRunContexts, + resolveCompactionModelFallbackOptions, resolveModelFallbackOptions, resolveProviderScopedAuthProfile, } = await import("./agent-runner-utils.js"); @@ -45,20 +36,21 @@ function makeRun(overrides: Partial = {}): FollowupRun["run" describe("agent-runner-utils", () => { beforeEach(() => { - hoisted.resolveRunModelFallbacksOverrideMock.mockClear(); + vi.restoreAllMocks(); }); it("resolves model fallback options from run context", () => { - hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); - const run = makeRun(); + const run = makeRun({ + sessionKey: "agent:agent-1:main", + config: { + agents: { + list: [{ id: "agent-1", model: { fallbacks: ["fallback-model"] } }], + }, + }, + }); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ - cfg: run.config, - agentId: run.agentId, - sessionKey: run.sessionKey, - }); expect(resolved).toEqual({ cfg: run.config, provider: run.provider, @@ -69,19 +61,93 @@ describe("agent-runner-utils", () => { }); it("passes through missing agentId for helper-based fallback resolution", () => { - hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); - const run = makeRun({ agentId: undefined }); + const run = makeRun({ + agentId: undefined, + sessionKey: "agent:agent-2:main", + config: { + agents: { + list: [{ id: "agent-2", model: { fallbacks: ["fallback-model"] } }], + }, + }, + }); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ - cfg: run.config, - agentId: undefined, - sessionKey: run.sessionKey, - }); expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); }); + it("uses compaction model override for compaction-scoped fallback resolution", () => { + const run = makeRun({ + sessionKey: "agent:agent-1:main", + config: { + agents: { + list: [{ id: "agent-1", model: { fallbacks: ["fallback-model"] } }], + defaults: { + compaction: { + model: "openrouter/anthropic/claude-sonnet-4-5", + }, + }, + }, + }, + }); + + const resolved = resolveCompactionModelFallbackOptions(run); + + expect(resolved).toEqual({ + cfg: run.config, + provider: "openrouter", + model: "anthropic/claude-sonnet-4-5", + agentDir: run.agentDir, + fallbacksOverride: ["openrouter/fallback-model"], + }); + }); + + it("rebases model-only fallbacks onto the compaction provider", () => { + const run = makeRun({ + sessionKey: "agent:agent-1:main", + config: { + agents: { + list: [{ id: "agent-1", model: { fallbacks: ["fallback-model", "anthropic/haiku"] } }], + defaults: { + compaction: { + model: "openrouter/anthropic/claude-sonnet-4-5", + }, + }, + }, + }, + }); + + const resolved = resolveCompactionModelFallbackOptions(run); + + expect(resolved.fallbacksOverride).toEqual(["openrouter/fallback-model", "anthropic/haiku"]); + }); + + it("keeps the primary provider when compaction model override omits a provider", () => { + const run = makeRun({ + sessionKey: "agent:agent-1:main", + config: { + agents: { + list: [{ id: "agent-1", model: { fallbacks: ["fallback-model"] } }], + defaults: { + compaction: { + model: "claude-sonnet-4-5", + }, + }, + }, + }, + }); + + const resolved = resolveCompactionModelFallbackOptions(run); + + expect(resolved).toEqual({ + cfg: run.config, + provider: run.provider, + model: "claude-sonnet-4-5", + agentDir: run.agentDir, + fallbacksOverride: ["fallback-model"], + }); + }); + it("builds embedded run base params with auth profile and run metadata", () => { const run = makeRun({ enforceFinalTag: true }); const authProfile = resolveProviderScopedAuthProfile({ @@ -191,7 +257,6 @@ describe("agent-runner-utils", () => { expect(context).toMatchObject({ currentChannelId: "telegram:-1003841603622", - currentThreadTs: "928", currentMessageId: "2284", }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index abf6322a287..612679a7b7a 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -168,6 +168,40 @@ export function resolveModelFallbackOptions(run: FollowupRun["run"]) { }; } +export function resolveCompactionModelFallbackOptions(run: FollowupRun["run"]) { + const resolved = resolveModelFallbackOptions(run); + const override = run.config?.agents?.defaults?.compaction?.model?.trim(); + if (!override) { + return resolved; + } + + const slashIdx = override.indexOf("/"); + if (slashIdx > 0) { + const provider = override.slice(0, slashIdx).trim(); + const model = override.slice(slashIdx + 1).trim(); + if (provider && model) { + const fallbacksOverride = resolved.fallbacksOverride?.map((fallback) => { + const trimmed = fallback.trim(); + if (!trimmed || trimmed.includes("/")) { + return trimmed; + } + return `${provider}/${trimmed}`; + }); + return { + ...resolved, + provider, + model, + fallbacksOverride, + }; + } + } + + return { + ...resolved, + model: override, + }; +} + export function buildEmbeddedRunBaseParams(params: { run: FollowupRun["run"]; provider: string;