From 2c272e271a3d784000e9db1ec8d04f1d34fbac64 Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Sat, 2 May 2026 23:14:30 +0800 Subject: [PATCH] fix(agents): preserve session model after heartbeat runs Fixes #75452. Heartbeat runs can use a per-turn model override via agents.defaults.heartbeat.model. Before this change, the run metadata was written back to the shared session store, so the next normal turn could inherit the heartbeat provider/model and a smaller context window. This lands the contributor fix plus maintainer polish: - preserve existing session runtime model/provider/context metadata when persisting heartbeat turns - avoid creating invalid provider/model pairs for legacy model-only session entries - leave empty prior runtime state unset for heartbeat-only turns - keep normal non-heartbeat runtime persistence unchanged - add focused regression coverage for the session-store edge cases - refresh heartbeat docs and changelog attribution Validation: - pnpm test src/agents/command/session-store.test.ts src/agents/openclaw-tools.session-status.test.ts - pnpm exec oxfmt --check --threads=1 src/agents/agent-command.ts src/agents/command/session-store.ts src/agents/command/session-store.test.ts CHANGELOG.md docs/gateway/heartbeat.md - git diff --check - GitHub checks on 42a00dcf38: clean; no active checks and no relevant failures Duplicate PR #75567 was already closed; #75557 is the canonical fix. --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 4 +- src/agents/agent-command.ts | 1 + src/agents/command/session-store.test.ts | 242 +++++++++++++++++++++++ src/agents/command/session-store.ts | 46 ++++- 5 files changed, 287 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee27940bb4d..29350c94865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao. - Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot. - Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser. +- Agents/sessions: preserve pre-existing runtime model and context window after heartbeat turns so a per-run heartbeat model override does not bleed into shared-session status. Fixes #75452. Thanks @zhangguiping-xydt. - Model commands: clarify direct and inline `/model` acknowledgements for non-default selections as session-scoped. Thanks @addu2612. - Doctor/gateway: stop warning that non-existent, unconfigured user-bin directories are required in the Gateway service PATH. Fixes #76017. Thanks @xiphis. - TUI/chat: skip full provider model normalization during context-window warmup while preserving provider-owned context metadata, avoiding cold-start stalls with large model registries. Thanks @547895019. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index bd357f1adf7..7c1630d20ab 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -479,9 +479,9 @@ Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce c ## Context overflow after heartbeat -If a heartbeat uses a smaller local model, for example an Ollama model with a 32k window, and the next main-session turn reports context overflow, check whether the previous heartbeat left the session on the heartbeat model. OpenClaw's reset message calls this out when the last runtime model matches configured `heartbeat.model`. +If a heartbeat previously left an existing session on a smaller local model, for example an Ollama model with a 32k window, and the next main-session turn reports context overflow, reset the session runtime model back to the configured primary model. OpenClaw's reset message calls this out when the last runtime model matches configured `heartbeat.model`. -Use `isolatedSession: true` to run heartbeats in a fresh session, combine it with `lightContext: true` for the smallest prompt, or choose a heartbeat model with a context window large enough for the shared session. +Current heartbeats preserve the shared session's existing runtime model after the run completes. You can still use `isolatedSession: true` to run heartbeats in a fresh session, combine it with `lightContext: true` for the smallest prompt, or choose a heartbeat model with a context window large enough for the shared session. ## Related diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index e23fabe7518..24edb0b9dd9 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1191,6 +1191,7 @@ async function agentCommandInternal( opts.bootstrapContextRunKind !== "cron" && opts.bootstrapContextRunKind !== "heartbeat" && !opts.internalEvents?.length, + preserveRuntimeModel: opts.bootstrapContextRunKind === "heartbeat", }); sessionEntry = sessionStore[sessionKey] ?? sessionEntry; } diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 7df0211bd8a..75033234924 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -877,6 +877,248 @@ describe("updateSessionStoreAfterAgentRun", () => { expect(sessionStore[sessionKey]?.lastInteractionAt).toBeGreaterThan(lastInteractionAt); }); }); + + it("preserves runtime model and contextTokens when preserveRuntimeModel is true (heartbeat bleed fix)", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-bleed"; + const sessionId = "test-heartbeat-bleed-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + modelProvider: "anthropic", + model: "claude-opus-4-6", + contextTokens: 1_000_000, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + // Heartbeat turn uses a different model + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "ollama", + model: "llama3.2:1b", + contextTokens: 128_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + result, + preserveRuntimeModel: true, + }); + + // Runtime model and contextTokens should be preserved from the original entry + expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic"); + expect(sessionStore[sessionKey]?.contextTokens).toBe(1_000_000); + + const persisted = loadSessionStore(storePath); + expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(persisted[sessionKey]?.modelProvider).toBe("anthropic"); + expect(persisted[sessionKey]?.contextTokens).toBe(1_000_000); + }); + }); + + it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-no-context-tokens"; + const sessionId = "test-heartbeat-no-context-tokens-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + modelProvider: "anthropic", + model: "claude-opus-4-6", + // contextTokens intentionally missing — older session without cached context + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + // Heartbeat turn uses a different, smaller model + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "ollama", + model: "llama3.2:1b", + contextTokens: 128_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + result, + preserveRuntimeModel: true, + }); + + // Runtime model should be preserved + expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic"); + // contextTokens should NOT bleed from the heartbeat run's smaller window + expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined(); + }); + }); + + it("does not set runtime model when preserveRuntimeModel is true and entry has no prior runtime model", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-new-session"; + const sessionId = "test-heartbeat-new-session-id"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "ollama", + model: "llama3.2:1b", + contextTokens: 128_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "ollama", + defaultModel: "llama3.2:1b", + result, + preserveRuntimeModel: true, + }); + + // Heartbeat should NOT establish initial model state on an empty session + expect(sessionStore[sessionKey]?.model).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined(); + expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined(); + }); + }); + + it("preserves model without borrowing heartbeat provider when entry has model but no modelProvider", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-model-no-provider"; + const sessionId = "test-heartbeat-model-no-provider-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + model: "claude-opus-4-6", + // modelProvider intentionally missing + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + // Heartbeat turn uses a different provider + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "ollama", + model: "llama3.2:1b", + contextTokens: 128_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + result, + preserveRuntimeModel: true, + }); + + // Model preserved, provider NOT borrowed from heartbeat + expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined(); + + const persisted = loadSessionStore(storePath); + expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(persisted[sessionKey]?.modelProvider).toBeUndefined(); + }); + }); + + it("overwrites runtime model when preserveRuntimeModel is false (default behavior)", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-normal-overwrite"; + const sessionId = "test-normal-overwrite-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + modelProvider: "anthropic", + model: "claude-opus-4-6", + contextTokens: 1_000_000, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.4", + contextTokens: 400_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result, + }); + + // Normal turn: runtime model is updated + expect(sessionStore[sessionKey]?.model).toBe("gpt-5.4"); + expect(sessionStore[sessionKey]?.modelProvider).toBe("openai"); + expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000); + }); + }); }); describe("clearCliSessionInStore", () => { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 8d29c5d63e0..0e5d5217efb 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -49,6 +49,13 @@ export async function updateSessionStoreAfterAgentRun(params: { fallbackModel?: string; result: RunResult; touchInteraction?: boolean; + /** + * When true, preserve the pre-existing runtime model fields (model, + * modelProvider, contextTokens) on the session entry instead of overwriting + * them with the model used by this run. Used for heartbeat turns so the + * heartbeat model does not "bleed" into the main session's perceived state. + */ + preserveRuntimeModel?: boolean; }) { const { cfg, @@ -91,6 +98,7 @@ export async function updateSessionStoreAfterAgentRun(params: { allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS); + const preserveRuntimeModel = params.preserveRuntimeModel === true; const entry = sessionStore[sessionKey] ?? { sessionId, updatedAt: now, @@ -102,12 +110,40 @@ export async function updateSessionStoreAfterAgentRun(params: { updatedAt: now, sessionStartedAt: entry.sessionId === sessionId ? (entry.sessionStartedAt ?? now) : now, lastInteractionAt: touchInteraction ? now : entry.lastInteractionAt, - contextTokens, + ...(preserveRuntimeModel + ? {} + : { + contextTokens, + }), }; - setSessionRuntimeModel(next, { - provider: providerUsed, - model: modelUsed, - }); + if (preserveRuntimeModel) { + // Keep the pre-existing runtime model and context window so a background + // heartbeat turn using a different model does not bleed into the main + // session's perceived state. + if (entry.model) { + // Prior runtime model exists: preserve its contextTokens. When missing, + // leave contextTokens unset rather than falling back to the heartbeat + // run's context window; status derives it from the preserved model. + next.contextTokens = entry.contextTokens; + if (entry.modelProvider) { + setSessionRuntimeModel(next, { + provider: entry.modelProvider, + model: entry.model, + }); + } else { + // Retain the model-only entry without borrowing the heartbeat provider + // to avoid invalid cross-provider pairs (e.g. ollama/claude-opus-4-6). + next.model = entry.model; + } + } + // When there is no prior runtime model, do nothing: a heartbeat turn + // should not establish initial model state on an empty session. + } else { + setSessionRuntimeModel(next, { + provider: providerUsed, + model: modelUsed, + }); + } if (agentHarnessId) { next.agentHarnessId = agentHarnessId; } else if (result.meta.executionTrace?.runner === "cli") {