Skip to content

Commit 04061bc

Browse files
committed
fix(agents): cap heartbeat context hint fallback
1 parent 88c49f9 commit 04061bc

3 files changed

Lines changed: 158 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.
1415
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
1516
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
1617
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,113 @@ describe("buildContextOverflowRecoveryText", () => {
450450
expect(text).not.toContain("reserveTokensFloor");
451451
});
452452

453+
it("uses the stored session context window as the uncataloged runtime model fallback", () => {
454+
const text = buildContextOverflowRecoveryText({
455+
cfg: {
456+
models: {
457+
providers: {
458+
openrouter: {
459+
baseUrl: "https://openrouter.test",
460+
models: [makeTestModel("qwen3.6-plus", 1_000_000)],
461+
},
462+
},
463+
},
464+
agents: {
465+
defaults: {
466+
contextTokens: 100_000,
467+
heartbeat: { model: "ollama/custom-32k" },
468+
},
469+
},
470+
},
471+
agentId: "agent",
472+
primaryProvider: "openrouter",
473+
primaryModel: "qwen3.6-plus",
474+
activeSessionEntry: {
475+
sessionId: "session",
476+
updatedAt: 1,
477+
modelProvider: "ollama",
478+
model: "custom-32k",
479+
contextTokens: 32_768,
480+
},
481+
});
482+
483+
expect(text).toContain("ollama/custom-32k (32k context)");
484+
expect(text).not.toContain("ollama/custom-32k (98k context)");
485+
expect(text).toContain("heartbeat model bleed");
486+
});
487+
488+
it("does not blame heartbeat when the stored session fallback matches the capped primary window", () => {
489+
const text = buildContextOverflowRecoveryText({
490+
cfg: {
491+
models: {
492+
providers: {
493+
openrouter: {
494+
baseUrl: "https://openrouter.test",
495+
models: [makeTestModel("qwen3.6-plus", 1_000_000)],
496+
},
497+
},
498+
},
499+
agents: {
500+
defaults: {
501+
contextTokens: 100_000,
502+
heartbeat: { model: "ollama/custom-large" },
503+
},
504+
},
505+
},
506+
agentId: "agent",
507+
primaryProvider: "openrouter",
508+
primaryModel: "qwen3.6-plus",
509+
activeSessionEntry: {
510+
sessionId: "session",
511+
updatedAt: 1,
512+
modelProvider: "ollama",
513+
model: "custom-large",
514+
contextTokens: 200_000,
515+
},
516+
});
517+
518+
expect(text).toContain("reserveTokensFloor");
519+
expect(text).not.toContain("heartbeat model bleed");
520+
});
521+
522+
it("does not blame heartbeat when the same agent cap constrains both cataloged models", () => {
523+
const text = buildContextOverflowRecoveryText({
524+
cfg: {
525+
models: {
526+
providers: {
527+
openrouter: {
528+
baseUrl: "https://openrouter.test",
529+
models: [makeTestModel("qwen3.6-plus", 1_000_000)],
530+
},
531+
ollama: {
532+
baseUrl: "http://ollama.test",
533+
models: [makeTestModel("custom-large", 1_000_000)],
534+
},
535+
},
536+
},
537+
agents: {
538+
defaults: {
539+
contextTokens: 100_000,
540+
heartbeat: { model: "ollama/custom-large" },
541+
},
542+
},
543+
},
544+
agentId: "agent",
545+
primaryProvider: "openrouter",
546+
primaryModel: "qwen3.6-plus",
547+
activeSessionEntry: {
548+
sessionId: "session",
549+
updatedAt: 1,
550+
modelProvider: "ollama",
551+
model: "custom-large",
552+
contextTokens: 1_000_000,
553+
},
554+
});
555+
556+
expect(text).toContain("reserveTokensFloor");
557+
expect(text).not.toContain("heartbeat model bleed");
558+
});
559+
453560
it("does not blame heartbeat when the smaller runtime model is not the configured heartbeat model", () => {
454561
const text = buildContextOverflowRecoveryText({
455562
cfg: {

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -745,26 +745,58 @@ function formatContextWindowLabel(tokens: number): string {
745745
return `${Math.round(tokens / 1024)}k`;
746746
}
747747

748+
function normalizePositiveContextTokens(value: unknown): number | undefined {
749+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
750+
return undefined;
751+
}
752+
return Math.floor(value);
753+
}
754+
755+
function resolveAgentContextTokensForHint(params: {
756+
cfg: FollowupRun["run"]["config"];
757+
agentId?: string;
758+
}): number | undefined {
759+
const defaultContextTokens = normalizePositiveContextTokens(
760+
params.cfg.agents?.defaults?.contextTokens,
761+
);
762+
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
763+
const agentContextTokens = agentId
764+
? normalizePositiveContextTokens(
765+
params.cfg.agents?.list?.find(
766+
(entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
767+
)?.contextTokens,
768+
)
769+
: undefined;
770+
return agentContextTokens ?? defaultContextTokens;
771+
}
772+
748773
function resolveContextWindowForHint(params: {
749774
cfg: FollowupRun["run"]["config"];
775+
agentId?: string;
750776
ref: ModelRefLike;
751777
activeSessionEntry?: SessionEntry;
752778
}) {
753-
const activeContextTokens =
754-
typeof params.activeSessionEntry?.contextTokens === "number" &&
755-
Number.isFinite(params.activeSessionEntry.contextTokens) &&
756-
params.activeSessionEntry.contextTokens > 0
757-
? Math.floor(params.activeSessionEntry.contextTokens)
758-
: undefined;
759-
return (
760-
activeContextTokens ??
761-
resolveContextTokensForModel({
762-
cfg: params.cfg,
763-
provider: params.ref.provider,
764-
model: params.ref.model,
765-
allowAsyncLoad: false,
766-
})
779+
const sessionContextTokens = normalizePositiveContextTokens(
780+
params.activeSessionEntry?.contextTokens,
767781
);
782+
const modelContextTokens = resolveContextTokensForModel({
783+
cfg: params.cfg,
784+
provider: params.ref.provider,
785+
model: params.ref.model,
786+
allowAsyncLoad: false,
787+
});
788+
const contextTokens = modelContextTokens ?? sessionContextTokens;
789+
if (contextTokens === undefined) {
790+
return undefined;
791+
}
792+
793+
const agentContextTokens = resolveAgentContextTokensForHint({
794+
cfg: params.cfg,
795+
agentId: params.agentId,
796+
});
797+
return agentContextTokens !== undefined
798+
? Math.min(agentContextTokens, contextTokens)
799+
: contextTokens;
768800
}
769801

770802
function resolveHeartbeatBleedHint(params: {
@@ -809,14 +841,14 @@ function resolveHeartbeatBleedHint(params: {
809841

810842
const runtimeWindow = resolveContextWindowForHint({
811843
cfg: params.cfg,
844+
agentId: params.agentId,
812845
ref: runtimeRef,
813846
activeSessionEntry: params.activeSessionEntry,
814847
});
815-
const primaryWindow = resolveContextTokensForModel({
848+
const primaryWindow = resolveContextWindowForHint({
816849
cfg: params.cfg,
817-
provider: primaryRef.provider,
818-
model: primaryRef.model,
819-
allowAsyncLoad: false,
850+
agentId: params.agentId,
851+
ref: primaryRef,
820852
});
821853
if (
822854
typeof runtimeWindow === "number" &&

0 commit comments

Comments
 (0)