mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00:00
fix(agents): prevent heartbeat model override from persisting in session state
This commit is contained in:
@@ -1186,6 +1186,7 @@ async function agentCommandInternal(
|
||||
opts.bootstrapContextRunKind !== "cron" &&
|
||||
opts.bootstrapContextRunKind !== "heartbeat" &&
|
||||
!opts.internalEvents?.length,
|
||||
preserveRuntimeModel: opts.bootstrapContextRunKind === "heartbeat",
|
||||
});
|
||||
sessionEntry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user