diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index d2a7decbf29..53ce7ed7607 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`. ### Using a different model -By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string: +By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`: ```json { @@ -76,6 +76,8 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp } ``` +Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider. + This works with local models too, for example a second Ollama model dedicated to summarization: ```json diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index cd5ddc7be56..de2597253cd 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -668,7 +668,7 @@ Periodic heartbeat runs. - `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit. - `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary. -- `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. +- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. 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. - `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`. - `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only. diff --git a/src/agents/embedded-agent-runner.cache.live.test.ts b/src/agents/embedded-agent-runner.cache.live.test.ts index 2fc7cc21448..88a72ee9a98 100644 --- a/src/agents/embedded-agent-runner.cache.live.test.ts +++ b/src/agents/embedded-agent-runner.cache.live.test.ts @@ -241,6 +241,8 @@ function normalizeLiveUsage( function buildEmbeddedRunnerConfig( params: LiveResolvedModel & { cacheRetention: "none" | "short" | "long"; + compactionModel?: string; + modelAlias?: string; transport?: "sse" | "websocket"; }, ): OpenClawConfig { @@ -264,12 +266,14 @@ function buildEmbeddedRunnerConfig( defaults: { models: { [modelKey]: { + ...(params.modelAlias ? { alias: params.modelAlias } : {}), params: { cacheRetention: params.cacheRetention, ...(params.transport ? { transport: params.transport } : {}), }, }, }, + ...(params.compactionModel ? { compaction: { model: params.compactionModel } } : {}), }, }, }; @@ -371,7 +375,9 @@ async function compactLiveCacheSession(params: { config: buildEmbeddedRunnerConfig({ apiKey: params.apiKey, cacheRetention: params.cacheRetention, + compactionModel: "live-compaction", model: params.model, + modelAlias: "live-compaction", }), provider: params.model.provider, model: params.model.id, diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index 2d77ec9bdd2..1b59a23aa3e 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -391,6 +391,162 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.authProfileId).toBeUndefined(); }); + it("resolves compaction.model alias to canonical model ref on same provider (#90340)", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.4-mini": { + alias: "gpt54mini", + params: { thinking: "high" }, + }, + }, + compaction: { model: "gpt54mini" }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-5.4-mini"); + expect(result.authProfileId).toBe("openai:default"); + }); + + it("resolves compaction.model alias to canonical model ref on different provider", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-6": { + alias: "thinky", + }, + }, + compaction: { model: "thinky" }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-opus-4-6"); + // Auth profile must be dropped when provider changes + expect(result.authProfileId).toBeUndefined(); + }); + + it("falls back to literal model when alias does not match", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.4-mini": { + alias: "gpt54mini", + }, + }, + compaction: { model: "nonexistent-alias" }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("nonexistent-alias"); + expect(result.authProfileId).toBe("openai:default"); + }); + + it("preserves auth when an omitted provider uses the effective default", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.4-mini": { + alias: "summary", + }, + }, + compaction: { model: "summary" }, + }, + }, + } as unknown as OpenClawConfig, + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-5.4-mini"); + expect(result.authProfileId).toBe("openai:default"); + }); + + it("prefers literal configured model ids over alias collisions (#90340)", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.4-mini": { + alias: "gpt54mini", + }, + "openai/gpt54mini": {}, + }, + compaction: { model: "gpt54mini" }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt54mini"); + expect(result.authProfileId).toBe("openai:default"); + }); + + it("keeps current-provider configured model ids over cross-provider alias collisions (#90340)", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-6": { + alias: "gpt-5.4-mini", + }, + }, + compaction: { model: "gpt-5.4-mini" }, + }, + }, + models: { + providers: { + openai: { models: [{ id: "gpt-5.4-mini" }] }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-5.4-mini"); + expect(result.authProfileId).toBe("openai:default"); + }); + it("leaves non-openai providers unchanged", () => { const result = resolveEmbeddedCompactionTarget({ provider: "anthropic", diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index fd9d29fbbe4..7d0b8f492bb 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -12,6 +12,12 @@ import { type ActiveProcessSessionReference, } from "../bash-process-references.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; +import { DEFAULT_PROVIDER } from "../defaults.js"; +import { + buildModelAliasIndex, + inferUniqueProviderFromConfiguredModels, + resolveModelRefFromString, +} from "../model-selection-shared.js"; import { openAIProviderUsesCodexRuntimeByDefault, resolveSelectedOpenAIRuntimeProvider, @@ -111,9 +117,7 @@ export function resolveEmbeddedCompactionTarget(params: { // When switching provider via override, drop the primary auth profile to // avoid sending the wrong credentials. const authProfileId = - overrideProvider !== (params.provider ?? "")?.trim() - ? undefined - : (params.authProfileId ?? undefined); + overrideProvider !== provider ? undefined : (params.authProfileId ?? undefined); return { provider: overrideProvider, ...resolveTargetProviders(overrideProvider, authProfileId), @@ -121,6 +125,59 @@ export function resolveEmbeddedCompactionTarget(params: { authProfileId, }; } + const config = params.config ?? {}; + const currentProvider = provider?.trim(); + if ( + currentProvider && + hasBareConfiguredModelForProvider({ + cfg: config, + provider: currentProvider, + model: override, + }) + ) { + const authProfileId = params.authProfileId ?? undefined; + return { + provider: currentProvider, + ...resolveTargetProviders(currentProvider, authProfileId), + model: override, + authProfileId, + }; + } + const inferredLiteralProvider = inferUniqueProviderFromConfiguredModels({ + cfg: config, + model: override, + }); + if (inferredLiteralProvider) { + const authProfileId = + inferredLiteralProvider !== provider ? undefined : (params.authProfileId ?? undefined); + return { + provider: inferredLiteralProvider, + ...resolveTargetProviders(inferredLiteralProvider, authProfileId), + model: override, + authProfileId, + }; + } + const defaultProvider = provider || DEFAULT_PROVIDER; + const aliasResolution = resolveModelRefFromString({ + cfg: config, + raw: override, + defaultProvider, + aliasIndex: buildModelAliasIndex({ + cfg: config, + defaultProvider, + }), + }); + if (aliasResolution?.alias) { + const resolvedProvider = aliasResolution.ref.provider; + const authProfileId = + resolvedProvider !== provider ? undefined : (params.authProfileId ?? undefined); + return { + provider: resolvedProvider, + ...resolveTargetProviders(resolvedProvider, authProfileId), + model: aliasResolution.ref.model, + authProfileId, + }; + } const authProfileId = params.authProfileId ?? undefined; return { provider, @@ -130,6 +187,42 @@ export function resolveEmbeddedCompactionTarget(params: { }; } +function normalizeCompactionConfigKey(value: string): string { + return value.trim().toLowerCase(); +} + +function hasBareConfiguredModelForProvider(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): boolean { + const providerKey = normalizeCompactionConfigKey(params.provider); + const modelKey = normalizeCompactionConfigKey(params.model); + if (!providerKey || !modelKey || params.model.includes("/")) { + return false; + } + for (const rawRef of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { + const slashIdx = rawRef.indexOf("/"); + if (slashIdx <= 0 || rawRef.endsWith("/*")) { + continue; + } + const rawProvider = rawRef.slice(0, slashIdx); + const rawModel = rawRef.slice(slashIdx + 1); + if ( + normalizeCompactionConfigKey(rawProvider) === providerKey && + normalizeCompactionConfigKey(rawModel) === modelKey + ) { + return true; + } + } + const configuredProvider = Object.entries(params.cfg.models?.providers ?? {}).find(([key]) => { + return normalizeCompactionConfigKey(key) === providerKey; + })?.[1]; + return (configuredProvider?.models ?? []).some((entry) => { + return normalizeCompactionConfigKey(entry?.id ?? "") === modelKey; + }); +} + function shouldUseCodexRuntimeProviderForCompaction(params: { config?: OpenClawConfig; provider: string; diff --git a/src/agents/embedded-agent-runner/run/attempt.test.ts b/src/agents/embedded-agent-runner/run/attempt.test.ts index 20ede1c57ac..691060b07e7 100644 --- a/src/agents/embedded-agent-runner/run/attempt.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.test.ts @@ -3396,8 +3396,13 @@ describe("buildAfterTurnRuntimeContext", () => { config: { agents: { defaults: { + models: { + "openrouter/anthropic/claude-sonnet-4-5": { + alias: "summary", + }, + }, compaction: { - model: "openrouter/anthropic/claude-sonnet-4-5", + model: "summary", }, }, }, @@ -3415,9 +3420,8 @@ describe("buildAfterTurnRuntimeContext", () => { agentDir: "/tmp/agent", }); - // buildEmbeddedCompactionRuntimeContext now resolves the override eagerly - // so that context engines (including third-party ones) receive the correct - // compaction model in the runtime context. + // Resolve aliases before handing runtime context to any context engine; + // otherwise third-party engines can dispatch the bare alias as a model id. expect(legacy.provider).toBe("openrouter"); expect(legacy.model).toBe("anthropic/claude-sonnet-4-5"); // Auth profile dropped because provider changed from openai to openrouter. diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 73bd08ff768..803c8d4b590 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -936,6 +936,7 @@ describe("config help copy quality", () => { const compactionModel = FIELD_HELP["agents.defaults.compaction.model"]; expect(/provider\/model|different model|primary agent model/i.test(compactionModel)).toBe(true); + expect(/alias/i.test(compactionModel)).toBe(true); const transcriptBytes = FIELD_HELP["agents.defaults.compaction.maxActiveTranscriptBytes"]; expect(/transcript|bytes|compaction/i.test(transcriptBytes)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 822e322482c..bed319a1d85 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1498,7 +1498,7 @@ export const FIELD_HELP: Record = { "agents.defaults.compaction.timeoutSeconds": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 180). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", "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.", + "Optional provider/model or configured bare alias used only for compaction summarization. Bare aliases resolve before dispatch; a configured literal model ID wins if it collides with an alias. Leave unset to keep using the primary agent model.", "agents.defaults.compaction.truncateAfterCompaction": "When enabled, rotates the active session JSONL file after compaction so future turns load only the summary and unsummarized tail while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: false.", "agents.defaults.compaction.maxActiveTranscriptBytes": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 798e344f66b..baeda38e121 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -538,7 +538,7 @@ export type AgentCompactionConfig = { * Explicit ["Session Startup", "Red Lines"] preserves legacy fallback headings. */ postCompactionSections?: string[]; - /** Optional model override for compaction summarization (e.g. "openrouter/anthropic/claude-sonnet-4-6"). + /** Optional provider/model or configured bare alias for compaction summarization. * When set, compaction uses this model instead of the agent's primary model. * Falls back to the primary model when unset. */ model?: string;