Summary
The in-loop context-overflow guard (installToolResultContextGuard) sizes each transcript message with estimateMessageChars, which special-cases only user, assistant, and toolResult roles. The harness roles bashExecution, compactionSummary, branchSummary, and custom fall through to a flat return 256, even though convertToLlm expands each of these into a full-text user message that is actually sent to the provider. As a result, the guard undercounts summary- and bash-dominated context and never trips its last-resort overflow protection for those transcripts, so an over-window prompt can be submitted.
This is a distinct, un-covered sibling of the just-merged #97861 (24626e5), which fixed the same defect class in the twin estimator estimateMessageTokenPressure (the pre-prompt precheck) but did not update this estimator that backs the live in-loop guard.
Environment
- Commit: 15de9d8 (origin/main at time of filing)
- Surface: embedded agent runner, in-loop tool-result context guard (
src/agents/embedded-agent-runner/tool-result-context-guard.ts -> tool-result-char-estimator.ts)
Steps to reproduce
- Drive the real guard's
transformContext (installed by installToolResultContextGuard) with a transcript whose real provider text far exceeds the context window, carried in harness roles (compactionSummary + bashExecution).
- Drive the same guard with byte-identical text carried in
user + toolResult roles.
- Observe that only the second transcript trips
PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE.
Command run (real runtime, no mocks of the guard):
node_modules/.bin/tsx /tmp/openclaw-bug-repros/audit-2026-06-29b/compaction/repro-context-guard-undercount.mjs
Expected
Both transcripts represent the same ~1,000,000-char provider payload (the harness roles expand to identical user text via convertToLlm), so both should trip the guard's overflow protection equally.
Actual
The harness-role transcript is let through (guard does not fire); the user+toolResult transcript fires. The guard is blind to summary/bash context size.
Root cause
src/agents/embedded-agent-runner/tool-result-char-estimator.ts:142 - estimateMessageChars handles user (line 89), assistant (line 100), and toolResult (line 132), then returns a flat 256 for everything else:
if (isToolResultMessage(msg)) {
// `details` is stripped before provider conversion; estimate only visible content.
const content = getToolResultContent(msg);
const chars = estimateContentBlockChars(content);
const weightedChars = Math.ceil(
chars * (CHARS_PER_TOKEN_ESTIMATE / TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE),
);
return Math.max(chars, weightedChars);
}
return 256;
}
But convertToLlm (packages/agent-core/src/harness/messages.ts:128) expands each harness role into a full-text user message that is sent to the provider:
bashExecution -> bashExecutionToText(message)
compactionSummary -> COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX
branchSummary -> BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIX
custom -> content
The guard runs transformContext on the un-expanded harness-role messages (before convertToLlm), so exceedsPreemptiveOverflowThreshold (tool-result-context-guard.ts:247, via estimateContextChars -> estimateMessageCharsCached) sums these as 256 each and never crosses maxContextChars. enforceToolResultLimitInPlace (tool-result-context-guard.ts:277) only truncates toolResult messages, so large bashExecution output is neither counted nor trimmed.
The fix mirrors #97861: estimate each affected role from the exact text convertToLlm renders (bashExecutionToText, the summary prefix/suffix plus summary text, and the custom content), returning 0 for excludeFromContext bash records.
Sibling surfaces
Severity
P1, with an honest mitigation: the pre-prompt precheck now uses the corrected estimator (#97861), so the realistic failure mode is mid-tool-loop accumulation of bash output slipping past this last-resort in-loop guard within a turn, rather than every turn. compactionSummary is present in every compacted session, so the undercount applies broadly, but the precheck catches the start-of-turn case.
Real behavior proof
Behavior addressed: in-loop overflow guard undercounts bashExecution/compactionSummary/branchSummary/custom as a flat 256 chars and fails to fire overflow protection on summary/bash-dominated context.
Real environment tested: real installToolResultContextGuard transformContext driven directly at commit 15de9d8; corrected run produced by patching estimateMessageChars locally to size those roles from the convertToLlm text, then reverted.
Exact steps or command run after this patch: node_modules/.bin/tsx /tmp/openclaw-bug-repros/audit-2026-06-29b/compaction/repro-context-guard-undercount.mjs
Evidence after fix:
# OBSERVED (buggy, origin/main 15de9d881a)
contextWindowTokens = 100000
maxContextChars = 360000
real big-text chars = 500000 x2 messages = 1000000
A harness roles (compactionSummary+bashExecution):
guard threw overflow = false
B visible roles (user+toolResult), identical text:
guard threw overflow = true (Context overflow: estimated context size exceeds safe threshold during tool loop.)
ACTUAL: A=false B=true
RESULT: BUG CONFIRMED - guard is BLIND to summary/bash context; over-window prompt is let through.
# EXPECTED (estimator counts harness roles from convertToLlm text; identical inputs)
contextWindowTokens = 100000
maxContextChars = 360000
real big-text chars = 500000 x2 messages = 1000000
A harness roles (compactionSummary+bashExecution):
guard threw overflow = true (Context overflow: estimated context size exceeds safe threshold during tool loop.)
B visible roles (user+toolResult), identical text:
guard threw overflow = true (Context overflow: estimated context size exceeds safe threshold during tool loop.)
ACTUAL: A=true B=true
Observed result after fix: on identical ~1,000,000-char payloads the guard fires for both role layouts only once the estimator sizes harness roles from their rendered text; on current main it fires only for the user/toolResult layout and lets the summary/bash layout through.
What was not tested: full gateway end-to-end submission to a live provider was not driven; the repro exercises the guard's transformContext directly. The corrected branch shown is a local demonstration patch, not a proposed final diff.
Summary
The in-loop context-overflow guard (
installToolResultContextGuard) sizes each transcript message withestimateMessageChars, which special-cases onlyuser,assistant, andtoolResultroles. The harness rolesbashExecution,compactionSummary,branchSummary, andcustomfall through to a flatreturn 256, even thoughconvertToLlmexpands each of these into a full-textusermessage that is actually sent to the provider. As a result, the guard undercounts summary- and bash-dominated context and never trips its last-resort overflow protection for those transcripts, so an over-window prompt can be submitted.This is a distinct, un-covered sibling of the just-merged #97861 (24626e5), which fixed the same defect class in the twin estimator
estimateMessageTokenPressure(the pre-prompt precheck) but did not update this estimator that backs the live in-loop guard.Environment
src/agents/embedded-agent-runner/tool-result-context-guard.ts->tool-result-char-estimator.ts)Steps to reproduce
transformContext(installed byinstallToolResultContextGuard) with a transcript whose real provider text far exceeds the context window, carried in harness roles (compactionSummary+bashExecution).user+toolResultroles.PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE.Command run (real runtime, no mocks of the guard):
Expected
Both transcripts represent the same ~1,000,000-char provider payload (the harness roles expand to identical user text via
convertToLlm), so both should trip the guard's overflow protection equally.Actual
The harness-role transcript is let through (guard does not fire); the
user+toolResulttranscript fires. The guard is blind to summary/bash context size.Root cause
src/agents/embedded-agent-runner/tool-result-char-estimator.ts:142-estimateMessageCharshandlesuser(line 89),assistant(line 100), andtoolResult(line 132), then returns a flat256for everything else:But
convertToLlm(packages/agent-core/src/harness/messages.ts:128) expands each harness role into a full-textusermessage that is sent to the provider:bashExecution->bashExecutionToText(message)compactionSummary->COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIXbranchSummary->BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIXcustom->contentThe guard runs
transformContexton the un-expanded harness-role messages (beforeconvertToLlm), soexceedsPreemptiveOverflowThreshold(tool-result-context-guard.ts:247, viaestimateContextChars->estimateMessageCharsCached) sums these as 256 each and never crossesmaxContextChars.enforceToolResultLimitInPlace(tool-result-context-guard.ts:277) only truncatestoolResultmessages, so largebashExecutionoutput is neither counted nor trimmed.The fix mirrors #97861: estimate each affected role from the exact text
convertToLlmrenders (bashExecutionToText, the summary prefix/suffix plus summary text, and thecustomcontent), returning 0 forexcludeFromContextbash records.Sibling surfaces
estimateMessageTokenPressureinsrc/agents/embedded-agent-runner/run/preemptive-compaction.ts(pre-prompt precheck): already fixed by fix(compaction): count bashExecution and summary turns in pre-prompt overflow precheck #97861 (24626e5). This issue is the remaining sibling.tool-result-char-estimator.test.tsonly exercisestoolResultroles, so this fallthrough is untested.Severity
P1, with an honest mitigation: the pre-prompt precheck now uses the corrected estimator (#97861), so the realistic failure mode is mid-tool-loop accumulation of bash output slipping past this last-resort in-loop guard within a turn, rather than every turn.
compactionSummaryis present in every compacted session, so the undercount applies broadly, but the precheck catches the start-of-turn case.Real behavior proof
Behavior addressed: in-loop overflow guard undercounts
bashExecution/compactionSummary/branchSummary/customas a flat 256 chars and fails to fire overflow protection on summary/bash-dominated context.Real environment tested: real
installToolResultContextGuardtransformContext driven directly at commit 15de9d8; corrected run produced by patchingestimateMessageCharslocally to size those roles from theconvertToLlmtext, then reverted.Exact steps or command run after this patch: node_modules/.bin/tsx /tmp/openclaw-bug-repros/audit-2026-06-29b/compaction/repro-context-guard-undercount.mjs
Evidence after fix:
Observed result after fix: on identical ~1,000,000-char payloads the guard fires for both role layouts only once the estimator sizes harness roles from their rendered text; on current main it fires only for the user/toolResult layout and lets the summary/bash layout through.
What was not tested: full gateway end-to-end submission to a live provider was not driven; the repro exercises the guard's
transformContextdirectly. The corrected branch shown is a local demonstration patch, not a proposed final diff.