fix(compaction): resolve model override in runtime context for all context engines (#56710)

Merged via squash.

Prepared head SHA: 72550aa5f0
Co-authored-by: oliviareid-svg <269669958+oliviareid-svg@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
oliviareid-svg 2026-04-01 06:25:16 +08:00 committed by GitHub
parent 78d1120a41
commit bf0f33db32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 251 deletions

View File

@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
## 2026.3.31
### Breaking

View File

@ -712,6 +712,41 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function");
});
it("resolves the effective compaction model before manual engine-owned compaction", async () => {
await compactEmbeddedPiSession(
wrappedCompactionArgs({
config: {
agents: {
defaults: {
compaction: {
model: "anthropic/claude-opus-4-6",
},
},
},
},
provider: "openai-codex",
model: "gpt-5.4",
authProfileId: "openai:p1",
}),
);
expect(resolveModelMock).toHaveBeenCalledWith(
"anthropic",
"claude-opus-4-6",
expect.any(String),
expect.anything(),
);
expect(contextEngineCompactMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeContext: expect.objectContaining({
provider: "anthropic",
model: "claude-opus-4-6",
authProfileId: undefined,
}),
}),
);
});
it("does not fire after_compaction when compaction fails", async () => {
hookRunner.hasHooks.mockReturnValue(true);
const sync = vi.fn(async () => {});

View File

@ -91,6 +91,10 @@ import {
runBeforeCompactionHooks,
runPostCompactionSideEffects,
} from "./compaction-hooks.js";
import {
buildEmbeddedCompactionRuntimeContext,
resolveEmbeddedCompactionTarget,
} from "./compaction-runtime-context.js";
import {
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
@ -286,31 +290,17 @@ export async function compactEmbeddedPiSessionDirect(
workspaceDir: resolvedWorkspace,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
});
// Resolve compaction model: prefer config override, then fall back to caller-supplied 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 resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
config: params.config,
provider: params.provider,
modelId: params.model,
authProfileId: params.authProfileId,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const provider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
const modelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
const authProfileId = resolvedCompactionTarget.authProfileId;
const fail = (reason: string): EmbeddedPiCompactResult => {
log.warn(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
@ -955,12 +945,19 @@ export async function compactEmbeddedPiSession(
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {
// Resolve token budget from model context window so the context engine
// knows the compaction target. The runner's afterTurn path passes this
// automatically, but the /compact command path needs to compute it here.
const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
config: params.config,
provider: params.provider,
modelId: params.model,
authProfileId: params.authProfileId,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
// Resolve token budget from the effective compaction model so engine-
// owned /compact implementations see the same target as the runtime.
const ceProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
const ceModelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
const { model: ceModel } = await resolveModelAsync(
ceProvider,
ceModelId,
@ -995,6 +992,32 @@ export async function compactEmbeddedPiSession(
workspaceDir: resolveUserPath(params.workspaceDir),
messageProvider: resolvedMessageProvider,
};
const runtimeContext = {
...params,
...buildEmbeddedCompactionRuntimeContext({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
authProfileId: params.authProfileId,
workspaceDir: params.workspaceDir,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId,
provider: params.provider,
modelId: params.model,
thinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
}),
};
// Engine-owned compaction doesn't load the transcript at this level, so
// message counts are unavailable. We pass sessionFile so hook subscribers
// can read the transcript themselves if they need exact counts.
@ -1022,7 +1045,7 @@ export async function compactEmbeddedPiSession(
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
customInstructions: params.customInstructions,
force: params.trigger === "manual",
runtimeContext: params as Record<string, unknown>,
runtimeContext,
});
if (result.ok && result.compacted) {
await runContextEngineMaintenance({
@ -1031,7 +1054,7 @@ export async function compactEmbeddedPiSession(
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
reason: "compaction",
runtimeContext: params as Record<string, unknown>,
runtimeContext,
});
}
if (engineOwnsCompaction && result.ok && result.compacted) {

View File

@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js";
import {
buildEmbeddedCompactionRuntimeContext,
resolveEmbeddedCompactionTarget,
} from "./compaction-runtime-context.js";
describe("buildEmbeddedCompactionRuntimeContext", () => {
it("preserves sender and current message routing for compaction", () => {
@ -74,4 +77,71 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
model: undefined,
});
});
it("applies compaction.model override with provider/model format", () => {
const result = buildEmbeddedCompactionRuntimeContext({
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
config: {
agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } },
} as OpenClawConfig,
provider: "ollama",
modelId: "minimax-m2.7:cloud",
authProfileId: "ollama:default",
});
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-opus-4-6");
// Auth profile dropped because provider changed
expect(result.authProfileId).toBeUndefined();
});
it("applies compaction.model override with model-only format", () => {
const result = buildEmbeddedCompactionRuntimeContext({
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
config: {
agents: { defaults: { compaction: { model: "gpt-4o" } } },
} as OpenClawConfig,
provider: "openai",
modelId: "gpt-3.5-turbo",
authProfileId: "openai:p1",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4o");
// Auth profile preserved because provider didn't change
expect(result.authProfileId).toBe("openai:p1");
});
it("uses session model when no compaction.model override configured", () => {
const result = buildEmbeddedCompactionRuntimeContext({
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
config: {} as OpenClawConfig,
provider: "ollama",
modelId: "minimax-m2.7:cloud",
authProfileId: "ollama:default",
});
expect(result.provider).toBe("ollama");
expect(result.model).toBe("minimax-m2.7:cloud");
expect(result.authProfileId).toBe("ollama:default");
});
it("applies runtime defaults when resolving the effective compaction target", () => {
expect(
resolveEmbeddedCompactionTarget({
config: {
agents: { defaults: { compaction: { model: "anthropic/" } } },
} as OpenClawConfig,
provider: "openai-codex",
modelId: "gpt-5.4",
authProfileId: "openai:p1",
defaultProvider: "openai-codex",
defaultModel: "gpt-5.4",
}),
).toEqual({
provider: "anthropic",
model: "gpt-5.4",
authProfileId: undefined,
});
});
});

View File

@ -27,6 +27,47 @@ export type EmbeddedCompactionRuntimeContext = {
ownerNumbers?: string[];
};
/**
* Resolve the effective compaction target from config, falling back to the
* caller-supplied provider/model and optionally applying runtime defaults.
*/
export function resolveEmbeddedCompactionTarget(params: {
config?: OpenClawConfig;
provider?: string | null;
modelId?: string | null;
authProfileId?: string | null;
defaultProvider?: string;
defaultModel?: string;
}): { provider: string | undefined; model: string | undefined; authProfileId: string | undefined } {
const provider = params.provider?.trim() || params.defaultProvider;
const model = params.modelId?.trim() || params.defaultModel;
const override = params.config?.agents?.defaults?.compaction?.model?.trim();
if (!override) {
return {
provider,
model,
authProfileId: params.authProfileId ?? undefined,
};
}
const slashIdx = override.indexOf("/");
if (slashIdx > 0) {
const overrideProvider = override.slice(0, slashIdx).trim();
const overrideModel = override.slice(slashIdx + 1).trim() || params.defaultModel;
// 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);
return { provider: overrideProvider, model: overrideModel, authProfileId };
}
return {
provider,
model: override,
authProfileId: params.authProfileId ?? undefined,
};
}
export function buildEmbeddedCompactionRuntimeContext(params: {
sessionKey?: string | null;
messageChannel?: string | null;
@ -50,6 +91,12 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
extraSystemPrompt?: string;
ownerNumbers?: string[];
}): EmbeddedCompactionRuntimeContext {
const resolved = resolveEmbeddedCompactionTarget({
config: params.config,
provider: params.provider,
modelId: params.modelId,
authProfileId: params.authProfileId,
});
return {
sessionKey: params.sessionKey ?? undefined,
messageChannel: params.messageChannel ?? undefined,
@ -58,15 +105,15 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
currentChannelId: params.currentChannelId ?? undefined,
currentThreadTs: params.currentThreadTs ?? undefined,
currentMessageId: params.currentMessageId ?? undefined,
authProfileId: params.authProfileId ?? undefined,
authProfileId: resolved.authProfileId,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId ?? undefined,
provider: params.provider ?? undefined,
model: params.modelId ?? undefined,
provider: resolved.provider,
model: resolved.model,
thinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,

View File

@ -1,34 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
isOllamaCompatProvider,
resolveOllamaBaseUrlForRun,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "../../../plugin-sdk/ollama.js";
import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js";
import { buildAgentSystemPrompt } from "../../system-prompt.js";
import { buildEmbeddedSystemPrompt } from "../system-prompt.js";
import {
buildAfterTurnRuntimeContext,
buildSessionsYieldContextMessage,
composeSystemPromptWithHookContext,
persistSessionsYieldContextMessage,
prependSystemPromptAddition,
queueSessionsYieldInterruptMessage,
resolveAttemptFsWorkspaceOnly,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
stripSessionsYieldArtifacts,
shouldInjectHeartbeatPrompt,
decodeHtmlEntitiesInObject,
wrapStreamFnRepairMalformedToolCallArguments,
wrapStreamFnSanitizeMalformedToolCalls,
wrapStreamFnTrimToolCallNames,
resolveEmbeddedAgentStreamFn,
} from "./attempt.js";
import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js";
type FakeWrappedStream = {
result: () => Promise<unknown>;
@ -152,98 +144,6 @@ describe("resolvePromptBuildHookResult", () => {
});
});
describe("sessions_yield helpers", () => {
it("builds a hidden follow-up context note", () => {
expect(buildSessionsYieldContextMessage("Waiting for subagent")).toContain(
"Waiting for subagent",
);
expect(buildSessionsYieldContextMessage("Waiting for subagent")).toContain(
"ended intentionally via sessions_yield",
);
});
it("queues a hidden interrupt steering message", () => {
const steer = vi.fn();
queueSessionsYieldInterruptMessage({ agent: { steer } });
expect(steer).toHaveBeenCalledWith(
expect.objectContaining({
role: "custom",
customType: "openclaw.sessions_yield_interrupt",
display: false,
details: { source: "sessions_yield" },
}),
);
});
it("persists a hidden yield context message without triggering a turn", async () => {
const sendCustomMessage = vi.fn(async () => {});
await persistSessionsYieldContextMessage(
{
sendCustomMessage,
},
"Waiting for subagent",
);
expect(sendCustomMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "openclaw.sessions_yield",
display: false,
details: { source: "sessions_yield", message: "Waiting for subagent" },
content: expect.stringContaining("Waiting for subagent"),
}),
{ triggerTurn: false },
);
});
it("strips trailing yield interrupt artifacts from memory and transcript state", () => {
const replaceMessages = vi.fn();
const rewriteFile = vi.fn();
const activeSession = {
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "custom", customType: "openclaw.sessions_yield_interrupt" },
{ role: "assistant", stopReason: "aborted" },
],
agent: { replaceMessages },
sessionManager: {
fileEntries: [
{ type: "session", id: "session-root" },
{
type: "custom_message",
id: "interrupt",
parentId: "session-root",
customType: "openclaw.sessions_yield_interrupt",
},
{
type: "message",
id: "aborted",
parentId: "interrupt",
message: { role: "assistant", stopReason: "aborted" },
},
],
byId: new Map([
["interrupt", { id: "interrupt" }],
["aborted", { id: "aborted" }],
]),
leafId: "aborted",
_rewriteFile: rewriteFile,
},
};
stripSessionsYieldArtifacts(activeSession as never);
expect(replaceMessages).toHaveBeenCalledWith([
{ role: "user", content: [{ type: "text", text: "hi" }] },
]);
expect(activeSession.sessionManager.fileEntries).toEqual([
{ type: "session", id: "session-root" },
]);
expect(activeSession.sessionManager.byId.has("interrupt")).toBe(false);
expect(activeSession.sessionManager.byId.has("aborted")).toBe(false);
expect(activeSession.sessionManager.leafId).toBe("session-root");
expect(rewriteFile).toHaveBeenCalledTimes(1);
});
});
describe("composeSystemPromptWithHookContext", () => {
it("returns undefined when no hook system context is provided", () => {
expect(composeSystemPromptWithHookContext({ baseSystemPrompt: "base" })).toBeUndefined();
@ -322,71 +222,6 @@ describe("resolvePromptModeForSession", () => {
});
});
describe("shouldInjectHeartbeatPrompt", () => {
it("uses trigger policy defaults for non-cron triggers", () => {
expect(shouldInjectHeartbeatPromptForTrigger("user")).toBe(true);
expect(shouldInjectHeartbeatPromptForTrigger("heartbeat")).toBe(true);
expect(shouldInjectHeartbeatPromptForTrigger("memory")).toBe(true);
expect(shouldInjectHeartbeatPromptForTrigger(undefined)).toBe(true);
});
it("uses trigger policy overrides for cron", () => {
expect(shouldInjectHeartbeatPromptForTrigger("cron")).toBe(false);
});
it("injects the heartbeat prompt for default-agent non-cron runs", () => {
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "user" })).toBe(true);
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "heartbeat" })).toBe(true);
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "memory" })).toBe(true);
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: undefined })).toBe(true);
});
it("suppresses the heartbeat prompt for cron-triggered runs", () => {
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: true, trigger: "cron" })).toBe(false);
});
it("suppresses the heartbeat prompt for non-default agents", () => {
expect(shouldInjectHeartbeatPrompt({ isDefaultAgent: false, trigger: "user" })).toBe(false);
});
it("omits heartbeat prompt content for cron-triggered full-mode runs on non-cron session keys", () => {
const sessionKey = "agent:main:kos:thread:abc";
expect(resolvePromptModeForSession(sessionKey)).toBe("full");
const heartbeatPrompt = shouldInjectHeartbeatPrompt({
isDefaultAgent: true,
trigger: "cron",
})
? resolveHeartbeatPrompt(undefined)
: undefined;
const prompt = buildEmbeddedSystemPrompt({
workspaceDir: "/tmp/openclaw",
defaultThinkLevel: "off",
reasoningLevel: "off",
reasoningTagHint: false,
heartbeatPrompt,
promptMode: resolvePromptModeForSession(sessionKey),
runtimeInfo: {
host: "host",
os: "Darwin",
arch: "arm64",
node: "v22.0.0",
model: "openai/gpt-5.4",
},
tools: [],
modelAliasLines: [],
userTimezone: "UTC",
userTime: "00:00",
userTimeFormat: "24",
});
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).not.toContain("HEARTBEAT_OK");
expect(prompt).not.toContain("Read HEARTBEAT.md");
});
});
describe("resolveAttemptFsWorkspaceOnly", () => {
it("uses global tools.fs.workspaceOnly when agent has no override", () => {
const cfg: OpenClawConfig = {
@ -1948,50 +1783,6 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
});
});
describe("resolveEmbeddedAgentStreamFn", () => {
it("keeps the session-managed HTTP stream when no override applies", () => {
const currentStreamFn = vi.fn();
const resolved = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: { provider: "xai" } as never,
});
expect(resolved).toBe(currentStreamFn);
});
it("keeps the session-managed HTTP stream when websocket auth is unavailable", () => {
const currentStreamFn = vi.fn();
const resolved = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
shouldUseWebSocketTransport: true,
wsApiKey: undefined,
sessionId: "session-1",
model: { provider: "xai" } as never,
});
expect(resolved).toBe(currentStreamFn);
});
it("prefers a provider-owned stream override when present", () => {
const currentStreamFn = vi.fn();
const providerStreamFn = vi.fn();
const resolved = resolveEmbeddedAgentStreamFn({
currentStreamFn: currentStreamFn as never,
providerStreamFn: providerStreamFn as never,
shouldUseWebSocketTransport: false,
sessionId: "session-1",
model: { provider: "xai" } as never,
});
expect(resolved).toBe(providerStreamFn);
});
});
describe("decodeHtmlEntitiesInObject", () => {
it("decodes HTML entities in string values", () => {
const result = decodeHtmlEntitiesInObject(
@ -2078,7 +1869,7 @@ describe("buildAfterTurnRuntimeContext", () => {
});
});
it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => {
it("resolves compaction.model override in runtime context so all context engines use the correct model", () => {
const legacy = buildAfterTurnRuntimeContext({
attempt: {
sessionKey: "agent:main:session:abc",
@ -2108,11 +1899,14 @@ describe("buildAfterTurnRuntimeContext", () => {
agentDir: "/tmp/agent",
});
// buildAfterTurnLegacyCompactionParams no longer resolves the override;
// compactEmbeddedPiSessionDirect does it centrally for both auto + manual paths.
// buildEmbeddedCompactionRuntimeContext now resolves the override eagerly
// so that context engines (including third-party ones) receive the correct
// compaction model in the runtime context.
expect(legacy).toMatchObject({
provider: "openai-codex",
model: "gpt-5.4",
provider: "openrouter",
model: "anthropic/claude-sonnet-4-5",
// Auth profile dropped because provider changed from openai-codex to openrouter
authProfileId: undefined,
});
});
it("includes resolved auth profile fields for context-engine afterTurn compaction", () => {