From f22cc1fa48a7eb7eefcd54f2e09631443bf579d6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 01:47:02 +0800 Subject: [PATCH] fix(ollama): preserve thinking across reply maintenance (#116963) Fixes #109527 --- .../compaction-runtime-context.test.ts | 11 +++++ .../compaction-runtime-context.ts | 4 +- .../direct-compaction-preparation.ts | 48 ++++++++++++++----- .../agent-runner-execution-lifecycle.test.ts | 20 ++++++++ .../reply/agent-runner-fallback-candidate.ts | 1 + .../reply/agent-runner-memory.test.ts | 36 ++++++++++++++ src/auto-reply/reply/agent-runner-memory.ts | 1 + .../reply/get-reply-run-admission.ts | 1 + src/auto-reply/reply/get-reply-run-execute.ts | 2 + .../reply/get-reply-run.media-only.test.ts | 7 +++ src/auto-reply/reply/queue/state.test.ts | 7 ++- src/auto-reply/reply/queue/state.ts | 1 + src/auto-reply/reply/queue/types.ts | 3 ++ 13 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index 9365237dcbe8..d1616cae799b 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -58,6 +58,17 @@ describe("resolveEmbeddedCompactionThinkingLevel", () => { }), ).toBe("off"); }); + + it("preserves thinking when the resolved Ollama model reports reasoning support", () => { + expect( + resolveEmbeddedCompactionThinkingLevel({ + provider: "ollama", + modelId: "qwen3.5:4b", + inheritedLevel: "high", + catalog: [{ provider: "ollama", id: "qwen3.5:4b", reasoning: true }], + }), + ).toBe("high"); + }); }); describe("buildEmbeddedCompactionRuntimeContext", () => { diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index 9b6a23501311..bd9e622a5a81 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -1,7 +1,7 @@ /** * Builds runtime context for context-engine backed embedded compaction. */ -import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { ThinkLevel, ThinkingCatalogEntry } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; @@ -66,6 +66,7 @@ export function resolveEmbeddedCompactionThinkingLevel(params: { provider: string; modelId: string; inheritedLevel?: ThinkLevel; + catalog?: ThinkingCatalogEntry[]; agentId?: string; sessionKey?: string; agentRuntime?: string | null; @@ -84,6 +85,7 @@ export function resolveEmbeddedCompactionThinkingLevel(params: { provider: params.provider, modelId: params.modelId, level: requestedLevel, + catalog: params.catalog, agentId: params.agentId, sessionKey: params.sessionKey, agentRuntime: params.agentRuntime, diff --git a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts index 584372313681..b3d7a346b912 100644 --- a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts +++ b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts @@ -3,7 +3,7 @@ * workspace, and sandbox resolution. */ import fs from "node:fs/promises"; -import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import type { ThinkLevel, ThinkingCatalogEntry } from "../../auto-reply/thinking.js"; import { createDiagnosticTraceContext, freezeDiagnosticTraceContext, @@ -87,7 +87,6 @@ export async function prepareDirectCompactionAttempt( runtimePolicySessionKey, runtimePolicyAgentId, boundHarnessRuntime, - selectedHarnessRuntime, selectedHarnessRuntimeOverride, runtimeModelAuth: { plan: reusableRuntimeAuthPlan, authProfileId, modelAuth: initialModelAuth }, provider, @@ -113,15 +112,6 @@ export async function prepareDirectCompactionAttempt( agentHarnessRuntimeOverride: selectedHarnessRuntimeOverride, workspaceDir: resolvedWorkspace, }); - const thinkLevel = resolveEmbeddedCompactionThinkingLevel({ - config: params.config, - provider, - modelId, - inheritedLevel: params.thinkLevel, - agentId: runtimePolicyAgentId, - sessionKey: runtimePolicySessionKey, - agentRuntime: selectedHarnessRuntime, - }); const attemptedThinking = new Set(); const fail = (reason: string, err?: unknown): EmbeddedAgentCompactResult => { const failureReason = classifyCompactionReason(reason); @@ -171,7 +161,7 @@ export async function prepareDirectCompactionAttempt( } // Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware // selection can pick the credential-owning harness (codex for ChatGPT OAuth); native - // transcript compaction stays gated on selectedHarnessRuntime. + // transcript compaction stays gated on the selected prepared harness. const { runtimeAuthProfileStore, runtimeAuthPreparation, @@ -297,6 +287,40 @@ export async function prepareDirectCompactionAttempt( const reason = formatErrorMessage(err); return { ok: false as const, result: fail(reason, err) }; } + const runtimeCompat = + runtimeModel.compat && typeof runtimeModel.compat === "object" + ? (runtimeModel.compat as Record) + : undefined; + const thinkingFormat = + typeof runtimeCompat?.thinkingFormat === "string" ? runtimeCompat.thinkingFormat : undefined; + const supportedReasoningEfforts = + runtimeCompat?.supportedReasoningEfforts === null || + (Array.isArray(runtimeCompat?.supportedReasoningEfforts) && + runtimeCompat.supportedReasoningEfforts.every((effort) => typeof effort === "string")) + ? (runtimeCompat.supportedReasoningEfforts as readonly string[] | null) + : undefined; + const thinkingCompat = + thinkingFormat !== undefined || supportedReasoningEfforts !== undefined + ? { thinkingFormat, supportedReasoningEfforts } + : undefined; + const thinkingCatalogEntry = { + provider: runtimeModel.provider, + id: runtimeModel.id, + api: runtimeModel.api, + reasoning: runtimeModel.reasoning, + params: runtimeModel.params, + ...(thinkingCompat ? { compat: thinkingCompat } : {}), + } satisfies ThinkingCatalogEntry; + const thinkLevel = resolveEmbeddedCompactionThinkingLevel({ + config: params.config, + provider: runtimeModel.provider, + modelId: runtimeModel.id, + inheritedLevel: params.thinkLevel, + catalog: [thinkingCatalogEntry], + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentRuntime: preparedHarnessRuntime, + }); await fs.mkdir(resolvedWorkspace, { recursive: true }); const sandboxSessionKey = diff --git a/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts b/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts index 5819b9dbeef3..56cb7cdb7f10 100644 --- a/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts @@ -115,6 +115,26 @@ describe("executeAgentTurn: run lifecycle and ownership", () => { expect(followupRun.run.thinkLevel).toBe("ultra"); }); + it("preserves thinking for runtime-discovered Ollama fallback models", async () => { + const followupRun = createFollowupRun(); + followupRun.run.provider = "openai"; + followupRun.run.model = "gpt-5.6-sol"; + followupRun.run.thinkLevel = "high"; + followupRun.run.thinkingCatalog = [{ provider: "ollama", id: "qwen3.5:4b", reasoning: true }]; + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => { + const result = await params.run("ollama", "qwen3.5:4b"); + return { result, provider: "ollama", model: "qwen3.5:4b", attempts: [] }; + }); + state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} }); + + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ + ...createMinimalRunAgentTurnParams({ followupRun }), + }); + + expect(state.runEmbeddedAgentMock.mock.calls[0]?.[0]?.thinkLevel).toBe("high"); + }); + it("freezes abort ownership only after model fallback settles", async () => { const { replyOperation, freezeAbortMock } = createMockReplyOperation(); const followupRun = createFollowupRun(); diff --git a/src/auto-reply/reply/agent-runner-fallback-candidate.ts b/src/auto-reply/reply/agent-runner-fallback-candidate.ts index 07a0a8b33763..4682a4403a38 100644 --- a/src/auto-reply/reply/agent-runner-fallback-candidate.ts +++ b/src/auto-reply/reply/agent-runner-fallback-candidate.ts @@ -112,6 +112,7 @@ export async function runAgentFallbackCandidates(params: AgentFallbackCycleParam provider, modelId: model, level: turn.followupRun.run.thinkLevel, + catalog: turn.followupRun.run.thinkingCatalog, agentId: turn.followupRun.run.agentId, sessionKey: turn.followupRun.run.runtimePolicySessionKey ?? turn.sessionKey, sessionEntry: turn.getActiveSessionEntry(), diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 4450810d4941..298a30acc7a6 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -650,6 +650,42 @@ describe("runMemoryFlushIfNeeded", () => { expect(followupRun.run.thinkLevel).toBe("ultra"); }); + it("preserves thinking for runtime-discovered Ollama memory-flush models", async () => { + const storePath = path.join(rootDir, "sessions.json"); + const sessionKey = "main"; + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + thinkingLevel: "high", + }; + const sessionStore = { [sessionKey]: sessionEntry }; + await writeTestSessionStore(storePath, sessionKey, sessionEntry); + const followupRun = createTestFollowupRun({ + provider: "ollama", + model: "qwen3.5:4b", + }); + followupRun.run.thinkLevel = "high"; + followupRun.run.thinkingCatalog = [{ provider: "ollama", id: "qwen3.5:4b", reasoning: true }]; + + await runMemoryFlushIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun, + sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext, + defaultModel: "ollama/qwen3.5:4b", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + sessionEntry, + sessionStore, + sessionKey, + storePath, + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect(requireEmbeddedAgentCall().thinkLevel).toBe("high"); + }); + it("keeps catalog-adopted sessions on Codex for memory flush turns", async () => { const sessionEntry: SessionEntry = { sessionId: "catalog-adopted-session", diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index ccf3f880c673..56fb33e7663b 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -1527,6 +1527,7 @@ export async function runMemoryFlushIfNeeded(params: { provider, modelId: model, level: params.followupRun.run.thinkLevel, + catalog: params.followupRun.run.thinkingCatalog, agentId: params.followupRun.run.agentId, sessionKey: params.runtimePolicySessionKey ?? diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts index 876e6c4c365f..535e155f6fa8 100644 --- a/src/auto-reply/reply/get-reply-run-admission.ts +++ b/src/auto-reply/reply/get-reply-run-admission.ts @@ -563,6 +563,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext) kind: "ready", context, resolvedThinkLevel, + thinkingCatalog, sessionEntry, skillsSnapshot, prefixedCommandBody, diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index 1b8fd9eebe62..97d5c7cc6044 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -39,6 +39,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) const { context, resolvedThinkLevel, + thinkingCatalog, skillsSnapshot, prefixedCommandBody, queuedBody, @@ -371,6 +372,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) autoFallbackPrimaryProbe: params.autoFallbackPrimaryProbe, authProfileId, authProfileIdSource, + thinkingCatalog, thinkLevel: resolvedThinkLevel, ...(() => { if (useFastReplyRuntime) { diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index a13e21ff3bd7..efcd3146a8bb 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -477,6 +477,13 @@ describe("runPreparedReply media-only handling", () => { expect(resolveThinkingCatalog).toHaveBeenCalledOnce(); const call = requireRunReplyAgentCall(); expect(call.followupRun.run.thinkLevel).toBe("off"); + expect(call.followupRun.run.thinkingCatalog).toEqual([ + { + provider: "openai", + id: "chat-latest", + reasoning: false, + }, + ]); }); it("reports unsupported explicit one-turn thinking overrides", async () => { diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts index 5d93dd1359cc..6869f51e717e 100644 --- a/src/auto-reply/reply/queue/state.test.ts +++ b/src/auto-reply/reply/queue/state.test.ts @@ -142,13 +142,18 @@ describe("refreshQueuedFollowupSession", () => { nextProvider: "openai", nextModel: "gpt-5.6-luna", nextRouteResolution: "resolved", - nextThinking: { level: "ultra", agentRuntime: "codex" }, + nextThinking: { + level: "ultra", + catalog: [{ provider: "openai", id: "gpt-5.6-luna", reasoning: true }], + agentRuntime: "codex", + }, }); expect(queue.items[0]?.run).toMatchObject({ provider: "openai", model: "gpt-5.6-luna", thinkLevel: "max", + thinkingCatalog: [{ provider: "openai", id: "gpt-5.6-luna", reasoning: true }], }); }); diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index 14c521478f00..4519c1c4ea49 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -256,6 +256,7 @@ export function refreshQueuedFollowupSession(params: { run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined; } if (params.nextThinking) { + run.thinkingCatalog = params.nextThinking.catalog; const explicitLevel = normalizeThinkLevel(params.nextThinking.level); run.thinkLevel = explicitLevel ? resolveSupportedThinkingLevel({ diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 0f4ca5ec72b2..796632716f05 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -24,6 +24,7 @@ import type { TurnAdoptionLifecycle, } from "../../get-reply-options.types.js"; import type { OriginatingChannelType } from "../../templating.js"; +import type { ThinkingCatalogEntry } from "../../thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; import { releaseRecentQueueMessageId } from "./recent-message-ids.js"; @@ -167,6 +168,8 @@ export type FollowupRun = { autoFallbackPrimaryProbe?: AutoFallbackPrimaryProbe; authProfileId?: string; authProfileIdSource?: "auto" | "user"; + /** Prepared model metadata reused when fallbacks revalidate the immutable thinking request. */ + thinkingCatalog?: ThinkingCatalogEntry[]; thinkLevel?: ThinkLevel; fastMode?: FastMode; fastModeAutoOnSeconds?: number;