diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 1e2919832ea..3eb7969e02c 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1186,6 +1186,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..4fe2908e10d 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -877,6 +877,149 @@ 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("falls back to run model when preserveRuntimeModel is true but 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, + }); + + // No prior runtime model, so falls back to the run's model + expect(sessionStore[sessionKey]?.model).toBe("llama3.2:1b"); + expect(sessionStore[sessionKey]?.modelProvider).toBe("ollama"); + expect(sessionStore[sessionKey]?.contextTokens).toBe(128_000); + }); + }); + + 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..9d7f9add0fd 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,27 @@ 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. + next.contextTokens = entry.contextTokens ?? contextTokens; + setSessionRuntimeModel(next, { + provider: entry.modelProvider ?? providerUsed, + model: entry.model ?? modelUsed, + }); + } else { + setSessionRuntimeModel(next, { + provider: providerUsed, + model: modelUsed, + }); + } if (agentHarnessId) { next.agentHarnessId = agentHarnessId; } else if (result.meta.executionTrace?.runner === "cli") {