fix(ollama): preserve thinking across reply maintenance (#116963)

Fixes #109527
This commit is contained in:
Vincent Koc
2026-08-01 01:47:02 +08:00
committed by GitHub
parent 8fc43e187a
commit f22cc1fa48
13 changed files with 128 additions and 14 deletions
@@ -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", () => {
@@ -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,
@@ -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<ThinkLevel>();
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<string, unknown>)
: 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 =
@@ -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();
@@ -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(),
@@ -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",
@@ -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 ??
@@ -563,6 +563,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
kind: "ready",
context,
resolvedThinkLevel,
thinkingCatalog,
sessionEntry,
skillsSnapshot,
prefixedCommandBody,
@@ -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) {
@@ -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 () => {
+6 -1
View File
@@ -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 }],
});
});
+1
View File
@@ -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({
+3
View File
@@ -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;