feat: allow compaction model override via config (#38753)

Merged via squash.

Prepared head SHA: a3d6d6c845c9ef492370c4cc12ea790ca92123f0
Co-authored-by: starbuck100 <25417736+starbuck100@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
GitBuck
2026-03-08 18:47:34 +01:00
committed by GitHub
parent b6520d7172
commit caf1b84822
12 changed files with 143 additions and 4 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm. - Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom. - Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs. - Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
### Breaking ### Breaking

View File

@@ -24,6 +24,36 @@ Compaction **persists** in the sessions JSONL history.
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.). Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`. Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "openrouter/anthropic/claude-sonnet-4-5"
}
}
}
}
```
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "ollama/llama3.1:8b"
}
}
}
}
```
When unset, compaction uses the agent's primary model.
## Auto-compaction (default on) ## Auto-compaction (default on)
When a session nears or exceeds the models context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context. When a session nears or exceeds the models context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.

View File

@@ -1005,6 +1005,7 @@ Periodic heartbeat runs.
identifierPolicy: "strict", // strict | off | custom identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override
memoryFlush: { memoryFlush: {
enabled: true, enabled: true,
softThresholdTokens: 6000, softThresholdTokens: 6000,
@@ -1021,6 +1022,7 @@ Periodic heartbeat runs.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
### `agents.defaults.contextPruning` ### `agents.defaults.contextPruning`

View File

@@ -86,6 +86,8 @@ const unitIsolatedFilesRaw = [
"src/slack/monitor/slash.test.ts", "src/slack/monitor/slash.test.ts",
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts", "src/imessage/monitor.shutdown.unhandled-rejection.test.ts",
// Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane.
"src/infra/git-commit.test.ts",
]; ];
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));

View File

@@ -52,7 +52,10 @@ describe("models-config merge helpers", () => {
it("merges explicit providers onto trimmed keys", () => { it("merges explicit providers onto trimmed keys", () => {
const merged = mergeProviders({ const merged = mergeProviders({
explicit: { explicit: {
" custom ": { api: "openai-responses", models: [] } as ProviderConfig, " custom ": {
api: "openai-responses",
models: [] as ProviderConfig["models"],
} as ProviderConfig,
}, },
}); });

View File

@@ -271,8 +271,31 @@ export async function compactEmbeddedPiSessionDirect(
const resolvedWorkspace = resolveUserPath(params.workspaceDir); const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd(); const prevCwd = process.cwd();
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; // Resolve compaction model: prefer config override, then fall back to caller-supplied model
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const compactionModelOverride = params.config?.agents?.defaults?.compaction?.model?.trim();
let provider: string;
let modelId: string;
// When switching provider via override, drop the primary auth profile to avoid
// sending the wrong credentials (e.g. OpenAI profile token to OpenRouter).
let authProfileId: string | undefined = params.authProfileId;
if (compactionModelOverride) {
const slashIdx = compactionModelOverride.indexOf("/");
if (slashIdx > 0) {
provider = compactionModelOverride.slice(0, slashIdx).trim();
modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL;
// Provider changed — drop primary auth profile so getApiKeyForModel
// falls back to provider-based key resolution for the override model.
if (provider !== (params.provider ?? "").trim()) {
authProfileId = undefined;
}
} else {
provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
modelId = compactionModelOverride;
}
} else {
provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
}
const fail = (reason: string): EmbeddedPiCompactResult => { const fail = (reason: string): EmbeddedPiCompactResult => {
log.warn( log.warn(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
@@ -302,7 +325,7 @@ export async function compactEmbeddedPiSessionDirect(
const apiKeyInfo = await getApiKeyForModel({ const apiKeyInfo = await getApiKeyForModel({
model, model,
cfg: params.config, cfg: params.config,
profileId: params.authProfileId, profileId: authProfileId,
agentDir, agentDir,
}); });

View File

@@ -639,6 +639,72 @@ describe("prependSystemPromptAddition", () => {
}); });
describe("buildAfterTurnLegacyCompactionParams", () => { describe("buildAfterTurnLegacyCompactionParams", () => {
it("uses primary model when compaction.model is not set", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",
messageProvider: "slack",
agentAccountId: "acct-1",
authProfileId: "openai:p1",
config: {} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.3-codex",
thinkLevel: "off",
reasoningLevel: "on",
extraSystemPrompt: "extra",
ownerNumbers: ["+15555550123"],
},
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
});
expect(legacy).toMatchObject({
provider: "openai-codex",
model: "gpt-5.3-codex",
});
});
it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",
messageProvider: "slack",
agentAccountId: "acct-1",
authProfileId: "openai:p1",
config: {
agents: {
defaults: {
compaction: {
model: "openrouter/anthropic/claude-sonnet-4-5",
},
},
},
} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.3-codex",
thinkLevel: "off",
reasoningLevel: "on",
extraSystemPrompt: "extra",
ownerNumbers: ["+15555550123"],
},
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
});
// buildAfterTurnLegacyCompactionParams no longer resolves the override;
// compactEmbeddedPiSessionDirect does it centrally for both auto + manual paths.
expect(legacy).toMatchObject({
provider: "openai-codex",
model: "gpt-5.3-codex",
});
});
it("includes resolved auth profile fields for context-engine afterTurn compaction", () => { it("includes resolved auth profile fields for context-engine afterTurn compaction", () => {
const legacy = buildAfterTurnLegacyCompactionParams({ const legacy = buildAfterTurnLegacyCompactionParams({
attempt: { attempt: {

View File

@@ -378,6 +378,7 @@ const TARGET_KEYS = [
"agents.defaults.compaction.qualityGuard.enabled", "agents.defaults.compaction.qualityGuard.enabled",
"agents.defaults.compaction.qualityGuard.maxRetries", "agents.defaults.compaction.qualityGuard.maxRetries",
"agents.defaults.compaction.postCompactionSections", "agents.defaults.compaction.postCompactionSections",
"agents.defaults.compaction.model",
"agents.defaults.compaction.memoryFlush", "agents.defaults.compaction.memoryFlush",
"agents.defaults.compaction.memoryFlush.enabled", "agents.defaults.compaction.memoryFlush.enabled",
"agents.defaults.compaction.memoryFlush.softThresholdTokens", "agents.defaults.compaction.memoryFlush.softThresholdTokens",
@@ -810,6 +811,9 @@ describe("config help copy quality", () => {
expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true); expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true);
expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true); expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true);
const compactionModel = FIELD_HELP["agents.defaults.compaction.model"];
expect(/provider\/model|different model|primary agent model/i.test(compactionModel)).toBe(true);
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"]; const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true); expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
}); });

View File

@@ -1013,6 +1013,8 @@ export const FIELD_HELP: Record<string, string> = {
"Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.",
"agents.defaults.compaction.postCompactionSections": "agents.defaults.compaction.postCompactionSections":
'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.', 'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.',
"agents.defaults.compaction.model":
"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"agents.defaults.compaction.memoryFlush": "agents.defaults.compaction.memoryFlush":
"Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.",
"agents.defaults.compaction.memoryFlush.enabled": "agents.defaults.compaction.memoryFlush.enabled":

View File

@@ -458,6 +458,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled", "agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled",
"agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries", "agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries",
"agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections", "agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections",
"agents.defaults.compaction.model": "Compaction Model Override",
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush", "agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled", "agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",
"agents.defaults.compaction.memoryFlush.softThresholdTokens": "agents.defaults.compaction.memoryFlush.softThresholdTokens":

View File

@@ -322,6 +322,10 @@ export type AgentCompactionConfig = {
* Set to [] to disable post-compaction context injection entirely. * Set to [] to disable post-compaction context injection entirely.
*/ */
postCompactionSections?: string[]; postCompactionSections?: string[];
/** Optional model override for compaction summarization (e.g. "openrouter/anthropic/claude-sonnet-4-5").
* When set, compaction uses this model instead of the agent's primary model.
* Falls back to the primary model when unset. */
model?: string;
}; };
export type AgentCompactionMemoryFlushConfig = { export type AgentCompactionMemoryFlushConfig = {

View File

@@ -104,6 +104,7 @@ export const AgentDefaultsSchema = z
.strict() .strict()
.optional(), .optional(),
postCompactionSections: z.array(z.string()).optional(), postCompactionSections: z.array(z.string()).optional(),
model: z.string().optional(),
memoryFlush: z memoryFlush: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),