fix(agents): prevent heartbeat model override from persisting in session state

This commit is contained in:
zhang-guiping
2026-05-01 17:15:40 +08:00
parent e62608beaa
commit 7e25003868
3 changed files with 172 additions and 5 deletions

View File

@@ -1186,6 +1186,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,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<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("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<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,
});
// 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<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,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") {