fix(compaction): resolve model override in runtime context for all context engines#56710
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f4e9b57a9c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const slashIdx = override.indexOf("/"); | ||
| if (slashIdx > 0) { | ||
| const overrideProvider = override.slice(0, slashIdx).trim(); | ||
| const overrideModel = override.slice(slashIdx + 1).trim() || undefined; |
There was a problem hiding this comment.
Preserve fallback model when override omits model id
When agents.defaults.compaction.model is configured as provider/ (or provider/ ), resolveCompactionModel now sets model to undefined via trim() || undefined. That is a regression for engine-owned compaction paths, because these engines consume runtimeContext.model directly and can skip/fail compaction when it is missing; the legacy runtime path previously tolerated this by falling back to a default model in compactEmbeddedPiSessionDirect. Aligning this branch with the existing fallback behavior avoids turning a recoverable config typo into compaction failure.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR fixes a real bug where the Key observations:
Confidence Score: 5/5Safe to merge — the core fix is correct, the double-resolution in the legacy path is idempotent, and all remaining findings are P2 style/cleanup concerns. All findings are P2 (duplicate logic, minor edge-case fallback divergence, unrelated test additions). No P0/P1 issues found. The fix is narrowly scoped and well-tested. No files require special attention, though compaction-runtime-context.ts and compact.ts carry duplicated override-parsing logic that should be consolidated as follow-up.
|
| Filename | Overview |
|---|---|
| src/agents/pi-embedded-runner/compaction-runtime-context.ts | Core fix: adds resolveCompactionModel and calls it inside buildEmbeddedCompactionRuntimeContext so every consumer receives the override-resolved provider/model/authProfileId. Logic is correct and the double-resolution in the legacy path is idempotent, but there is a minor fallback divergence from compact.ts for the malformed 'provider/' edge case. |
| src/agents/pi-embedded-runner/compaction-runtime-context.test.ts | Three new unit tests cover the three main override shapes (provider/model, model-only, no override) with correct assertions including the auth-profile-drop behaviour on provider change. |
| src/agents/pi-embedded-runner/run/attempt.test.ts | Updates the existing compaction override test to expect the override to be eagerly resolved in the runtime context (correct). Also includes model-version fixture bumps and several unrelated new test suites. No correctness issues found. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/compaction-runtime-context.ts
Line: 51-60
Comment:
**Duplicate override resolution logic**
`resolveCompactionModel` here and the equivalent block in `compactEmbeddedPiSessionDirect` (`compact.ts` lines 410–427) are now two independent copies of the same parsing logic. Because `delegateCompactionToRuntime` spreads the pre-resolved runtime context directly into `compactEmbeddedPiSessionDirect`, the override in `compact.ts` is re-applied on top of values that already reflect the override — it is idempotent in that path, but it's dead/redundant work and a maintenance liability. If the parsing semantics ever diverge, the legacy and custom-engine paths will silently produce different results.
Consider extracting the shared parsing into a small exported helper (e.g. `parseCompactionModelOverride`) that both sites import, so there is one source of truth. This doesn't need to block the PR, but it's worth tracking as follow-up.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/compaction-runtime-context.ts
Line: 53
Comment:
**Empty-model fallback diverges from `compact.ts`**
When the override string ends with a slash (e.g. `"anthropic/"`), this line yields `undefined`:
```ts
const overrideModel = override.slice(slashIdx + 1).trim() || undefined;
```
whereas the parallel code in `compactEmbeddedPiSessionDirect` falls back to `DEFAULT_MODEL`:
```ts
modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL;
```
For the legacy path the final value is `DEFAULT_MODEL` (the override in `compact.ts` corrects it). For a custom context engine with `ownsCompaction=true` the runtime context it receives will have `model: undefined`, which the engine must handle itself. The input is almost certainly invalid, but a consistent fallback between both sites would make the behaviour more predictable.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.test.ts
Line: 153-250
Comment:
**Unrelated test additions in a focused compaction PR**
The `sessions_yield helpers`, `shouldInjectHeartbeatPrompt`, and `resolveEmbeddedAgentStreamFn` test suites added here are unrelated to the compaction model override fix described in the PR. Bundling unrelated test additions makes the diff harder to review and muddies the git history. Consider splitting them into a separate PR or commit if the team's convention is to keep PRs focused. Not a correctness issue — just a scope observation.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "test(compaction): update attempt test fo..." | Re-trigger Greptile
| if (slashIdx > 0) { | ||
| const overrideProvider = override.slice(0, slashIdx).trim(); | ||
| const overrideModel = override.slice(slashIdx + 1).trim() || undefined; | ||
| // 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 }; |
There was a problem hiding this comment.
Duplicate override resolution logic
resolveCompactionModel here and the equivalent block in compactEmbeddedPiSessionDirect (compact.ts lines 410–427) are now two independent copies of the same parsing logic. Because delegateCompactionToRuntime spreads the pre-resolved runtime context directly into compactEmbeddedPiSessionDirect, the override in compact.ts is re-applied on top of values that already reflect the override — it is idempotent in that path, but it's dead/redundant work and a maintenance liability. If the parsing semantics ever diverge, the legacy and custom-engine paths will silently produce different results.
Consider extracting the shared parsing into a small exported helper (e.g. parseCompactionModelOverride) that both sites import, so there is one source of truth. This doesn't need to block the PR, but it's worth tracking as follow-up.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/compaction-runtime-context.ts
Line: 51-60
Comment:
**Duplicate override resolution logic**
`resolveCompactionModel` here and the equivalent block in `compactEmbeddedPiSessionDirect` (`compact.ts` lines 410–427) are now two independent copies of the same parsing logic. Because `delegateCompactionToRuntime` spreads the pre-resolved runtime context directly into `compactEmbeddedPiSessionDirect`, the override in `compact.ts` is re-applied on top of values that already reflect the override — it is idempotent in that path, but it's dead/redundant work and a maintenance liability. If the parsing semantics ever diverge, the legacy and custom-engine paths will silently produce different results.
Consider extracting the shared parsing into a small exported helper (e.g. `parseCompactionModelOverride`) that both sites import, so there is one source of truth. This doesn't need to block the PR, but it's worth tracking as follow-up.
How can I resolve this? If you propose a fix, please make it concise.| const slashIdx = override.indexOf("/"); | ||
| if (slashIdx > 0) { | ||
| const overrideProvider = override.slice(0, slashIdx).trim(); | ||
| const overrideModel = override.slice(slashIdx + 1).trim() || undefined; |
There was a problem hiding this comment.
Empty-model fallback diverges from
compact.ts
When the override string ends with a slash (e.g. "anthropic/"), this line yields undefined:
const overrideModel = override.slice(slashIdx + 1).trim() || undefined;whereas the parallel code in compactEmbeddedPiSessionDirect falls back to DEFAULT_MODEL:
modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL;For the legacy path the final value is DEFAULT_MODEL (the override in compact.ts corrects it). For a custom context engine with ownsCompaction=true the runtime context it receives will have model: undefined, which the engine must handle itself. The input is almost certainly invalid, but a consistent fallback between both sites would make the behaviour more predictable.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/compaction-runtime-context.ts
Line: 53
Comment:
**Empty-model fallback diverges from `compact.ts`**
When the override string ends with a slash (e.g. `"anthropic/"`), this line yields `undefined`:
```ts
const overrideModel = override.slice(slashIdx + 1).trim() || undefined;
```
whereas the parallel code in `compactEmbeddedPiSessionDirect` falls back to `DEFAULT_MODEL`:
```ts
modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL;
```
For the legacy path the final value is `DEFAULT_MODEL` (the override in `compact.ts` corrects it). For a custom context engine with `ownsCompaction=true` the runtime context it receives will have `model: undefined`, which the engine must handle itself. The input is almost certainly invalid, but a consistent fallback between both sites would make the behaviour more predictable.
How can I resolve this? If you propose a fix, please make it concise.| }); | ||
| }); | ||
|
|
||
| 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(); |
There was a problem hiding this comment.
Unrelated test additions in a focused compaction PR
The sessions_yield helpers, shouldInjectHeartbeatPrompt, and resolveEmbeddedAgentStreamFn test suites added here are unrelated to the compaction model override fix described in the PR. Bundling unrelated test additions makes the diff harder to review and muddies the git history. Consider splitting them into a separate PR or commit if the team's convention is to keep PRs focused. Not a correctness issue — just a scope observation.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.test.ts
Line: 153-250
Comment:
**Unrelated test additions in a focused compaction PR**
The `sessions_yield helpers`, `shouldInjectHeartbeatPrompt`, and `resolveEmbeddedAgentStreamFn` test suites added here are unrelated to the compaction model override fix described in the PR. Bundling unrelated test additions makes the diff harder to review and muddies the git history. Consider splitting them into a separate PR or commit if the team's convention is to keep PRs focused. Not a correctness issue — just a scope observation.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7dbe8a026a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const slashIdx = override.indexOf("/"); | ||
| if (slashIdx > 0) { | ||
| const overrideProvider = override.slice(0, slashIdx).trim(); | ||
| const overrideModel = override.slice(slashIdx + 1).trim() || params.defaultModel; |
There was a problem hiding this comment.
Fall back to caller model when override suffix is empty
When agents.defaults.compaction.model is set like provider/, this branch resolves overrideModel to params.defaultModel only, so calls that do not supply runtime defaults return model: undefined even if a caller model was provided. buildEmbeddedCompactionRuntimeContext invokes this helper without defaults, so engine-owned compaction can receive a runtime context with no model and fail or skip compaction in that configuration. Use the already-resolved caller model as the fallback (not just defaultModel) to keep behavior consistent across entrypoints.
Useful? React with 👍 / 👎.
7dbe8a0 to
794d964
Compare
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Auth profile dropped based on raw provider string, enabling credential fallback/confusion
Description
This creates a credential-confusion edge case:
then Impact depends on deployment, but can include:
Vulnerable code: const authProfileId =
overrideProvider !== (params.provider ?? "")?.trim()
? undefined
: (params.authProfileId ?? undefined);This logic is also used via RecommendationCompare against the effective provider (after applying defaults), not the raw caller input, and ensure defaults are consistently passed. For example: export function resolveEmbeddedCompactionTarget(params: {
// ...
defaultProvider?: string;
defaultModel?: string;
}) {
const effectiveProvider = params.provider?.trim() || params.defaultProvider;
const effectiveModel = params.modelId?.trim() || params.defaultModel;
const override = params.config?.agents?.defaults?.compaction?.model?.trim();
if (!override) return { provider: effectiveProvider, model: effectiveModel, 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;
const authProfileId =
overrideProvider !== (effectiveProvider ?? "")
? undefined
: (params.authProfileId ?? undefined);
return { provider: overrideProvider, model: overrideModel, authProfileId };
}
return { provider: effectiveProvider, model: override, authProfileId: params.authProfileId ?? undefined };
}Also update 2. 🟠 Config-controlled compaction provider/model override can exfiltrate transcripts to unintended LLM providers
DescriptionThe embedded Pi compaction path allows Key points:
Vulnerable code (provider/model override parsing): const override = params.config?.agents?.defaults?.compaction?.model?.trim();
...
if (slashIdx > 0) {
const overrideProvider = override.slice(0, slashIdx).trim();
const overrideModel = override.slice(slashIdx + 1).trim() || params.defaultModel;
...
return { provider: overrideProvider, model: overrideModel, authProfileId };
}Vulnerable code (override used to select runtime provider/model for compaction): const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ config: params.config, ... });
const provider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
const modelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
const { model } = await resolveModelAsync(provider, modelId, agentDir, params.config);RecommendationTreat
Example (conceptual) validation/allowlist: const allowed = new Set(["openai/gpt-4o", "anthropic/claude-sonnet-4-5"]);
const requested = `${provider}/${modelId}`;
if (!allowed.has(requested)) {
throw new Error(`Compaction model not allowed: ${requested}`);
}3. 🟡 Sensitive data exposure: full compaction params/config passed to third-party ContextEngine via runtimeContext
Description
Because context engines can be registered via the public SDK (
Vulnerable code (from changed lines): const runtimeContext = {
...params,
...buildEmbeddedCompactionRuntimeContext({
// ...
config: params.config,
skillsSnapshot: params.skillsSnapshot,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
}),
};
const result = await contextEngine.compact({
// ...
runtimeContext,
});Even if most engines are trusted, this design makes it easy for a third-party engine (or a compromised plugin dependency) to exfiltrate sensitive configuration and user metadata. RecommendationDo not pass the full Suggested fix (use a minimal allowlisted schema and avoid passing full // Only expose what engines actually need
const runtimeContext = buildEmbeddedCompactionRuntimeContext({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
// consider omitting authProfileId/config/ownerNumbers unless strictly needed
workspaceDir: resolveUserPath(params.workspaceDir),
agentDir,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId,
provider: params.provider,
modelId: params.model,
});
await contextEngine.compact({ ... , runtimeContext });If engines need configuration, pass a redacted snapshot (e.g., remove secrets/tokens/passwords and any auth-profile material), and document the contract so plugin authors cannot rely on hidden fields from Analyzed PR: #56710 at commit Last updated on: 2026-03-31T17:37:38Z |
794d964 to
79aac03
Compare
f80f9dd to
c8b3f4c
Compare
…ntext engines The compaction.model config override was only resolved inside compactEmbeddedPiSessionDirect (compact.ts), which is only reached by the legacy context engine. Custom context engines with their own compact() implementation never saw the override, causing overflow and timeout recovery to use the session's default model instead. Move the override resolution into buildEmbeddedCompactionRuntimeContext so the runtime context always carries the correct compaction model regardless of which context engine handles the compact() call. Fixes openclaw#56649 Co-Authored-By: Claude Opus 4.6 <[email protected]>
…context Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
db7f14e to
72550aa
Compare
|
Merged via squash.
Thanks @oliviareid-svg! |
…ntext engines (#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
…ntext engines (openclaw#56710) Merged via squash. Prepared head SHA: 72550aa Co-authored-by: oliviareid-svg <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Summary
agents.defaults.compaction.modelconfig override was only resolved insidecompactEmbeddedPiSessionDirect(compact.ts), which is only reached by the legacy context engine'scompact()viadelegateCompactionToRuntime.ownsCompaction=truethat implement their owncompact()never saw the override, causing overflow and timeout recovery to use the session's default model.buildEmbeddedCompactionRuntimeContextso the runtime context always carries the correct compaction provider/model regardless of which context engine handles thecompact()call.compact.ts).Test plan
compaction-runtime-context.test.tscovering override with provider/model format, model-only format, and no-override fallbackattempt.test.tsto expect the override to be resolved eagerly in the runtime contextFixes #56649
🤖 Generated with Claude Code