Skip to content

Commit c098846

Browse files
authored
fix: add compaction model fallback (#74470)
* fix: add compaction model fallback * docs: add compaction changelog pr reference * docs: add compaction changelog author * docs: satisfy compaction changelog attribution * fix: preserve compaction fallback metadata * fix: satisfy compaction fallback lint * docs: move compaction fallback changelog entry
1 parent b119cef commit c098846

14 files changed

Lines changed: 395 additions & 5 deletions

CHANGELOG.md

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

99
- Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.
10+
- Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.
1011

1112
## 2026.4.30
1213

docs/concepts/compaction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ This works with local models too, for example a second Ollama model dedicated to
8989
}
9090
```
9191

92-
When unset, compaction uses the agent's primary model.
92+
When unset, compaction starts with the active session model. If summarization fails with a model-fallback-eligible provider error, OpenClaw retries that compaction attempt through the session's existing model fallback chain. The fallback choice is temporary and is not written back to session state. An explicit `agents.defaults.compaction.model` override remains exact and does not inherit the session fallback chain.
9393

9494
### Identifier preservation
9595

src/agents/agent-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,7 @@ async function agentCommandInternal(
968968
return attemptExecutionRuntime.runAgentAttempt({
969969
providerOverride,
970970
modelOverride,
971+
modelFallbacksOverride: effectiveFallbacksOverride,
971972
originalProvider: provider,
972973
cfg,
973974
sessionEntry,

src/agents/command/attempt-execution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ export function runAgentAttempt(params: {
343343
sessionStore?: Record<string, SessionEntry>;
344344
storePath?: string;
345345
allowTransientCooldownProbe?: boolean;
346+
modelFallbacksOverride?: string[];
346347
sessionHasHistory?: boolean;
347348
}) {
348349
const isRawModelRun = params.opts.modelRun === true || params.opts.promptMode === "none";
@@ -575,6 +576,7 @@ export function runAgentAttempt(params: {
575576
clientTools: params.opts.clientTools,
576577
provider: params.providerOverride,
577578
model: params.modelOverride,
579+
modelFallbacksOverride: params.modelFallbacksOverride,
578580
authProfileId,
579581
authProfileIdSource: authProfileId ? harnessAuthSelection.authProfileIdSource : undefined,
580582
thinkLevel: params.resolvedThinkLevel,

src/agents/pi-embedded-runner/compact.hooks.harness.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,10 +503,15 @@ export async function loadCompactHooksHarness(): Promise<{
503503
listAgentEntries: vi.fn(() => []),
504504
resolveAgentConfig: vi.fn(() => undefined),
505505
resolveDefaultAgentId: vi.fn(() => "main"),
506+
resolveRunModelFallbacksOverride: vi.fn(() => undefined),
506507
resolveSessionAgentId: resolveSessionAgentIdMock,
507508
resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })),
508509
}));
509510

511+
vi.doMock("../auth-profiles/source-check.js", () => ({
512+
hasAnyAuthProfileStoreSource: vi.fn(() => false),
513+
}));
514+
510515
vi.doMock("../memory-search.js", () => ({
511516
resolveMemorySearchConfig: resolveMemorySearchConfigMock,
512517
}));

src/agents/pi-embedded-runner/compact.hooks.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,258 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
309309
);
310310
});
311311

312+
it("uses the session model fallback chain when implicit compaction fails", async () => {
313+
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
314+
model: { provider, api: "responses", id: modelId, input: [] },
315+
error: null,
316+
authStorage: { setRuntimeApiKey: vi.fn() },
317+
modelRegistry: {},
318+
}));
319+
sessionCompactImpl
320+
.mockRejectedValueOnce(
321+
Object.assign(
322+
new Error(
323+
"400 The response was filtered due to the prompt triggering Azure OpenAI's content management policy.",
324+
),
325+
{ status: 400 },
326+
),
327+
)
328+
.mockResolvedValueOnce({
329+
summary: "fallback summary",
330+
firstKeptEntryId: "entry-fallback",
331+
tokensBefore: 120,
332+
details: { ok: true },
333+
});
334+
335+
const result = await compactEmbeddedPiSessionDirect({
336+
sessionId: "session-1",
337+
sessionKey: TEST_SESSION_KEY,
338+
sessionFile: "/tmp/session.jsonl",
339+
workspaceDir: "/tmp/workspace",
340+
provider: "openai",
341+
model: "gpt-primary",
342+
config: {
343+
agents: {
344+
defaults: {
345+
model: {
346+
primary: "openai/gpt-primary",
347+
fallbacks: ["anthropic/claude-fallback"],
348+
},
349+
},
350+
},
351+
} as never,
352+
});
353+
354+
expect(result.ok).toBe(true);
355+
expect(result.result?.summary).toBe("fallback summary");
356+
expect(resolveModelMock).toHaveBeenCalledWith(
357+
"openai",
358+
"gpt-primary",
359+
expect.any(String),
360+
expect.anything(),
361+
);
362+
expect(resolveModelMock).toHaveBeenCalledWith(
363+
"anthropic",
364+
"claude-fallback",
365+
expect.any(String),
366+
expect.anything(),
367+
);
368+
});
369+
370+
it("uses the session model fallback chain when overflow compaction fails", async () => {
371+
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
372+
model: { provider, api: "responses", id: modelId, input: [] },
373+
error: null,
374+
authStorage: { setRuntimeApiKey: vi.fn() },
375+
modelRegistry: {},
376+
}));
377+
sessionCompactImpl
378+
.mockRejectedValueOnce(
379+
Object.assign(new Error("primary compaction rate limited"), {
380+
status: 429,
381+
code: "rate_limit_exceeded",
382+
}),
383+
)
384+
.mockResolvedValueOnce({
385+
summary: "overflow fallback summary",
386+
firstKeptEntryId: "entry-fallback",
387+
tokensBefore: 120,
388+
details: { ok: true },
389+
});
390+
391+
const result = await compactEmbeddedPiSessionDirect({
392+
sessionId: "session-1",
393+
sessionKey: TEST_SESSION_KEY,
394+
sessionFile: "/tmp/session.jsonl",
395+
workspaceDir: "/tmp/workspace",
396+
provider: "openai",
397+
model: "gpt-primary",
398+
trigger: "overflow",
399+
modelFallbacksOverride: ["anthropic/claude-fallback"],
400+
config: {
401+
agents: {
402+
defaults: {
403+
model: {
404+
primary: "openai/gpt-primary",
405+
fallbacks: [],
406+
},
407+
},
408+
},
409+
} as never,
410+
});
411+
412+
expect(result.ok).toBe(true);
413+
expect(result.result?.summary).toBe("overflow fallback summary");
414+
expect(resolveModelMock).toHaveBeenCalledWith(
415+
"openai",
416+
"gpt-primary",
417+
expect.any(String),
418+
expect.anything(),
419+
);
420+
expect(resolveModelMock).toHaveBeenCalledWith(
421+
"anthropic",
422+
"claude-fallback",
423+
expect.any(String),
424+
expect.anything(),
425+
);
426+
});
427+
428+
it("keeps compaction fallback selection ephemeral", async () => {
429+
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
430+
model: { provider, api: "responses", id: modelId, input: [] },
431+
error: null,
432+
authStorage: { setRuntimeApiKey: vi.fn() },
433+
modelRegistry: {},
434+
}));
435+
sessionCompactImpl
436+
.mockRejectedValueOnce(Object.assign(new Error("400 invalid request body"), { status: 400 }))
437+
.mockResolvedValueOnce({
438+
summary: "fallback summary",
439+
firstKeptEntryId: "entry-fallback",
440+
tokensBefore: 120,
441+
details: { ok: true },
442+
});
443+
const config = {
444+
agents: {
445+
defaults: {
446+
model: {
447+
primary: "openai/gpt-primary",
448+
fallbacks: ["anthropic/claude-fallback"],
449+
},
450+
},
451+
},
452+
sessions: {
453+
entries: {
454+
[TEST_SESSION_KEY]: {
455+
modelProvider: "openai",
456+
model: "gpt-primary",
457+
},
458+
},
459+
},
460+
};
461+
const configBefore = structuredClone(config);
462+
463+
const result = await compactEmbeddedPiSessionDirect({
464+
sessionId: "session-1",
465+
sessionKey: TEST_SESSION_KEY,
466+
sessionFile: "/tmp/session.jsonl",
467+
workspaceDir: "/tmp/workspace",
468+
provider: "openai",
469+
model: "gpt-primary",
470+
config: config as never,
471+
});
472+
473+
expect(result.ok).toBe(true);
474+
expect(config).toEqual(configBefore);
475+
});
476+
477+
it("preserves explicit compaction.model behavior without session fallback", async () => {
478+
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
479+
model: { provider, api: "responses", id: modelId, input: [] },
480+
error: null,
481+
authStorage: { setRuntimeApiKey: vi.fn() },
482+
modelRegistry: {},
483+
}));
484+
sessionCompactImpl.mockRejectedValueOnce(
485+
Object.assign(new Error("400 invalid request body"), { status: 400 }),
486+
);
487+
488+
const result = await compactEmbeddedPiSessionDirect({
489+
sessionId: "session-1",
490+
sessionKey: TEST_SESSION_KEY,
491+
sessionFile: "/tmp/session.jsonl",
492+
workspaceDir: "/tmp/workspace",
493+
provider: "openai",
494+
model: "gpt-primary",
495+
config: {
496+
agents: {
497+
defaults: {
498+
model: {
499+
primary: "openai/gpt-primary",
500+
fallbacks: ["anthropic/claude-fallback"],
501+
},
502+
compaction: {
503+
model: "azure/compact-primary",
504+
},
505+
},
506+
},
507+
} as never,
508+
});
509+
510+
expect(result.ok).toBe(false);
511+
expect(resolveModelMock).toHaveBeenCalledTimes(1);
512+
expect(resolveModelMock).toHaveBeenCalledWith(
513+
"azure",
514+
"compact-primary",
515+
expect.any(String),
516+
expect.anything(),
517+
);
518+
});
519+
520+
it("preserves compaction failure status and code metadata", async () => {
521+
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
522+
model: { provider, api: "responses", id: modelId, input: [] },
523+
error: null,
524+
authStorage: { setRuntimeApiKey: vi.fn() },
525+
modelRegistry: {},
526+
}));
527+
sessionCompactImpl.mockRejectedValueOnce(
528+
Object.assign(new Error("primary compaction rate limited"), {
529+
status: 429,
530+
code: "rate_limit_exceeded",
531+
}),
532+
);
533+
534+
const result = await compactEmbeddedPiSessionDirect({
535+
sessionId: "session-1",
536+
sessionKey: TEST_SESSION_KEY,
537+
sessionFile: "/tmp/session.jsonl",
538+
workspaceDir: "/tmp/workspace",
539+
provider: "openai",
540+
model: "gpt-primary",
541+
config: {
542+
agents: {
543+
defaults: {
544+
compaction: {
545+
model: "openai/gpt-primary",
546+
},
547+
},
548+
},
549+
} as never,
550+
});
551+
552+
expect(result).toMatchObject({
553+
ok: false,
554+
compacted: false,
555+
failure: {
556+
reason: "rate_limit",
557+
status: 429,
558+
code: "rate_limit_exceeded",
559+
rawError: "primary compaction rate limited",
560+
},
561+
});
562+
});
563+
312564
it("emits internal + plugin compaction hooks with counts", async () => {
313565
hookRunner.hasHooks.mockReturnValue(true);
314566
await runCompactionHooks({

src/agents/pi-embedded-runner/compact.queued.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ function buildCompactionContextEngineRuntimeContext(params: {
321321
senderId: params.params.senderId,
322322
provider: params.params.provider,
323323
modelId: params.params.model,
324+
modelFallbacksOverride: params.params.modelFallbacksOverride,
324325
thinkLevel: params.params.thinkLevel,
325326
reasoningLevel: params.params.reasoningLevel,
326327
bashElevated: params.params.bashElevated,

0 commit comments

Comments
 (0)