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.
This commit is contained in:
zhang-guiping
2026-05-02 23:14:30 +08:00
committed by GitHub
parent ad0d87d881
commit 2c272e271a
5 changed files with 287 additions and 7 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -1191,6 +1191,7 @@ async function agentCommandInternal(
opts.bootstrapContextRunKind !== "cron" &&
opts.bootstrapContextRunKind !== "heartbeat" &&
!opts.internalEvents?.length,
preserveRuntimeModel: opts.bootstrapContextRunKind === "heartbeat",
});
sessionEntry = sessionStore[sessionKey] ?? sessionEntry;
}

View File

@@ -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<string, SessionEntry> = {
[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<string, SessionEntry> = {
[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<string, SessionEntry> = {
[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<string, SessionEntry> = {
[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<string, SessionEntry> = {
[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", () => {

View File

@@ -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") {