fix(hooks): context-monitor noise — loop-detection false positives + per-call cost-warning spam (#2486)

* fix(hooks): context-monitor noise — loop-detection false positives and per-call cost-warning spam

Two independent noise sources in the PostToolUse context monitor injected
agent-facing warnings on nearly every tool call:

1. LOOP WARNING false positives. hashToolCall() hashed only the first 160
   chars of a Bash command, so distinct long commands sharing a prefix
   (heredocs, long one-liners) collided and consecutive DIFFERENT calls
   looked like a stuck loop. Additionally LOOP_THRESHOLD=3 against a
   5-entry ring buffer fired on legitimate repetition (retries, polling).
   Fix: hash the full command (digest truncated, not the input — same
   treatment the Edit/Write branch already got), and require all 5 of the
   last 5 calls to be identical before warning.

2. COST NOTICE spam. run() deduped warnings on exact message text, but the
   cost figure embedded in the text moves on nearly every call, so once a
   session crossed $5 a 'new' COST NOTICE was injected per tool call for
   the rest of the session. Context warnings had the same defect via the
   remaining-% figure. Fix: dedupe on a stable per-tier key
   (cost:notice/warning/critical, context:warning/critical, scope) so each
   tier fires exactly once and re-fires only on genuine escalation. The
   existing ECC_CONTEXT_MONITOR_COST_WARNINGS opt-out is unchanged.

Tests: loop threshold updated (5-of-5 fires, 4-of-5 does not), long
shared-prefix Bash hash regression, and a run()-level tier-dedupe test
(notice fires once, silent on cost tick, re-emits on escalation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: keep context warning state immutable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
AlbertChiu777
2026-08-10 22:33:10 -04:00
committed by GitHub
co-authored by Claude Fable 5 haelyra
parent 0e0df5a6e7
commit c7720d41bb
4 changed files with 109 additions and 28 deletions
+34 -20
View File
@@ -21,7 +21,12 @@ const COST_NOTICE_USD = 5;
const COST_WARNING_USD = 10; const COST_WARNING_USD = 10;
const COST_CRITICAL_USD = 50; const COST_CRITICAL_USD = 50;
const FILES_WARNING_COUNT = 20; const FILES_WARNING_COUNT = 20;
const LOOP_THRESHOLD = 3; // The recent_tools ring buffer holds 5 entries (RECENT_TOOLS_SIZE in
// ecc-metrics-bridge.js), so 5 means ALL of the last 5 calls must be the
// identical tool+params before a LOOP WARNING fires. At 3, three repeats of
// a legitimate command (retries, polling) among five mixed calls fired a
// false warning.
const LOOP_THRESHOLD = 5;
const STALE_SECONDS = 60; const STALE_SECONDS = 60;
function isEnabledEnv(value, defaultValue = true) { function isEnabledEnv(value, defaultValue = true) {
@@ -56,7 +61,7 @@ function readWarnState(sessionId) {
try { try {
return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8')); return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8'));
} catch { } catch {
return { callsSinceWarn: 0, lastSeverity: null, lastMessage: null }; return { callsSinceWarn: 0, lastSeverity: null, lastKey: null };
} }
} }
@@ -123,6 +128,7 @@ function evaluateConditions(bridge, options = {}) {
warnings.push({ warnings.push({
severity: 3, severity: 3,
type: 'context', type: 'context',
dedupeKey: 'context:critical',
message: message:
`CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` + `CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` +
'Inform the user that context is low and ask how they want to proceed. ' + 'Inform the user that context is low and ask how they want to proceed. ' +
@@ -132,6 +138,7 @@ function evaluateConditions(bridge, options = {}) {
warnings.push({ warnings.push({
severity: 2, severity: 2,
type: 'context', type: 'context',
dedupeKey: 'context:warning',
message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.' message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.'
}); });
} }
@@ -144,18 +151,21 @@ function evaluateConditions(bridge, options = {}) {
warnings.push({ warnings.push({
severity: 3, severity: 3,
type: 'cost', type: 'cost',
dedupeKey: 'cost:critical',
message: `COST CRITICAL: session total ~$${cost.toFixed(2)} (over $${COST_CRITICAL_USD}). Informational only — not an instruction to stop.` message: `COST CRITICAL: session total ~$${cost.toFixed(2)} (over $${COST_CRITICAL_USD}). Informational only — not an instruction to stop.`
}); });
} else if (cost > COST_WARNING_USD) { } else if (cost > COST_WARNING_USD) {
warnings.push({ warnings.push({
severity: 2, severity: 2,
type: 'cost', type: 'cost',
dedupeKey: 'cost:warning',
message: `COST WARNING: session total ~$${cost.toFixed(2)} (over $${COST_WARNING_USD}). Informational only.` message: `COST WARNING: session total ~$${cost.toFixed(2)} (over $${COST_WARNING_USD}). Informational only.`
}); });
} else if (cost > COST_NOTICE_USD) { } else if (cost > COST_NOTICE_USD) {
warnings.push({ warnings.push({
severity: 1, severity: 1,
type: 'cost', type: 'cost',
dedupeKey: 'cost:notice',
message: `COST NOTICE: session total ~$${cost.toFixed(2)}. Informational only.` message: `COST NOTICE: session total ~$${cost.toFixed(2)}. Informational only.`
}); });
} }
@@ -167,6 +177,7 @@ function evaluateConditions(bridge, options = {}) {
warnings.push({ warnings.push({
severity: 2, severity: 2,
type: 'scope', type: 'scope',
dedupeKey: 'scope',
message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.' message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.'
}); });
} }
@@ -177,6 +188,8 @@ function evaluateConditions(bridge, options = {}) {
warnings.push({ warnings.push({
severity: 2, severity: 2,
type: 'loop', type: 'loop',
// The message itself is a stable key: same tool looping again is a
// duplicate; a different tool or count is a new event.
message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.' message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.'
}); });
} }
@@ -224,37 +237,38 @@ function run(rawInput) {
// duplicate. Only write when there is state to clear — most tool calls // duplicate. Only write when there is state to clear — most tool calls
// have no warning, and this keeps the common path free of disk writes. // have no warning, and this keeps the common path free of disk writes.
const prior = readWarnState(sessionId); const prior = readWarnState(sessionId);
if (prior.lastMessage) { if (prior.lastKey || prior.lastMessage) {
writeWarnState(sessionId, { callsSinceWarn: 0, lastSeverity: null, lastMessage: null }); writeWarnState(sessionId, { callsSinceWarn: 0, lastSeverity: null, lastKey: null });
} }
return rawInput; return rawInput;
} }
// Combine top 2 warnings // Combine top 2 warnings
const message = warnings const top = warnings.slice(0, 2);
.slice(0, 2) const message = top.map(w => w.message).join('\n');
.map(w => w.message)
.join('\n');
// Dedupe on message content, not a call counter. The previous logic // Dedupe on the warning TIER (dedupeKey), not the message text. Message
// re-emitted the *same* warning every DEBOUNCE_CALLS tool calls, so a // text embeds continuously-moving numbers (cost in dollars, context %),
// single unchanged condition (e.g. a cost figure that only refreshes at // so text-based dedupe re-emitted the "same" warning on nearly every
// turn boundaries) printed the identical line ~20 times in one turn. Now a // tool call — a COST NOTICE fired once per call for the rest of the
// warning is surfaced only when its text changes (cost moved, a new file // session once cost passed $5. Each tier now fires once (notice →
// count, a new loop) or when we newly escalate to critical — genuinely new // warning → critical each re-fire on escalation), and a genuinely new
// information — and is otherwise suppressed. // event (different loop, tier change) still surfaces.
const dedupeKey = top.map(w => w.dedupeKey || w.message).join('\n');
const warnState = readWarnState(sessionId); const warnState = readWarnState(sessionId);
const topSeverity = severityLabel(warnings[0].severity); const topSeverity = severityLabel(warnings[0].severity);
const escalatedToCritical = topSeverity === 'critical' && warnState.lastSeverity !== 'critical'; const escalatedToCritical = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
const sameMessage = warnState.lastMessage === message; const sameKey = warnState.lastKey === dedupeKey;
if (sameMessage && !escalatedToCritical) { if (sameKey && !escalatedToCritical) {
return rawInput; return rawInput;
} }
warnState.lastSeverity = topSeverity; writeWarnState(sessionId, {
warnState.lastMessage = message; ...warnState,
writeWarnState(sessionId, warnState); lastSeverity: topSeverity,
lastKey: dedupeKey,
});
const output = { const output = {
hookSpecificOutput: { hookSpecificOutput: {
+5 -1
View File
@@ -47,7 +47,11 @@ function hashToolCall(toolName, toolInput) {
const name = String(toolName || ''); const name = String(toolName || '');
let key = ''; let key = '';
if (name === 'Bash') { if (name === 'Bash') {
key = String(toolInput?.command || '').slice(0, 160); // Hash the FULL command (digest, not a prefix slice): taking the first
// 160 chars collided distinct long commands that share a common prefix
// (heredocs, long one-liners), so consecutive DIFFERENT Bash calls looked
// like a stuck loop and triggered false LOOP WARNINGs.
key = crypto.createHash('sha256').update(String(toolInput?.command || '')).digest('hex');
} else if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(name)) { } else if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(name)) {
// Fingerprint the actual change, not just the path. Hashing on file_path // Fingerprint the actual change, not just the path. Hashing on file_path
// alone made every distinct edit to the same file collide, so a few normal // alone made every distinct edit to the same file collide, so a few normal
+55 -7
View File
@@ -176,6 +176,40 @@ function runTests() {
passed++; passed++;
else failed++; else failed++;
if (
test('cost warnings dedupe by tier: notice fires once, re-fires on escalation', () => {
const sessionId = `ctx-monitor-tier-dedupe-${process.pid}-${Date.now()}`;
const warnPath = path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`);
const input = JSON.stringify({ session_id: sessionId, tool_name: 'Bash' });
const setCost = cost =>
writeBridgeAtomic(sessionId, { total_cost_usd: cost, last_timestamp: new Date().toISOString() });
try {
setCost(6);
const first = run(input);
assert.ok(
JSON.parse(first).hookSpecificOutput.additionalContext.includes('COST NOTICE'),
'first crossing of the notice threshold must emit'
);
setCost(6.4); // cost ticks up within the same tier — must stay silent
const second = run(input);
assert.strictEqual(second, input, 'same tier must not re-emit on every cost tick');
setCost(12); // tier escalation notice → warning must re-emit
const third = run(input);
assert.ok(
JSON.parse(third).hookSpecificOutput.additionalContext.includes('COST WARNING'),
'tier escalation must re-emit'
);
} finally {
fs.rmSync(getBridgePath(sessionId), { force: true });
fs.rmSync(warnPath, { force: true });
}
})
)
passed++;
else failed++;
// evaluateConditions — scope warnings // evaluateConditions — scope warnings
console.log('\nevaluateConditions (scope):'); console.log('\nevaluateConditions (scope):');
@@ -205,16 +239,30 @@ function runTests() {
console.log('\ndetectLoop:'); console.log('\ndetectLoop:');
if ( if (
test('3 identical entries returns detected true', () => { test('5 identical entries returns detected true', () => {
const entries = [ const entries = Array(5).fill({ tool: 'Bash', hash: 'aabbccdd' });
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' }
];
const result = detectLoop(entries); const result = detectLoop(entries);
assert.strictEqual(result.detected, true); assert.strictEqual(result.detected, true);
assert.strictEqual(result.tool, 'Bash'); assert.strictEqual(result.tool, 'Bash');
assert.ok(result.count >= 3); assert.ok(result.count >= 5);
})
)
passed++;
else failed++;
if (
test('4 identical among 5 entries returns detected false', () => {
// Legitimate repetition (retries, polling) must not fire: only a full
// ring buffer of identical calls counts as a stuck loop.
const entries = [
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'ffffffff' }
];
const result = detectLoop(entries);
assert.strictEqual(result.detected, false);
}) })
) )
passed++; passed++;
+15
View File
@@ -97,6 +97,21 @@ function runTests() {
passed++; passed++;
else failed++; else failed++;
if (
test('long Bash commands diverging only after 160 chars still hash differently', () => {
// Shared prefix longer than the old 160-char command slice; the
// commands differ only afterwards (heredocs, long one-liners). Hashing
// the full command must keep them distinct, otherwise consecutive
// different Bash calls look like a stuck loop.
const prefix = 'python3 - <<EOF\n' + '# '.repeat(120);
const h1 = hashToolCall('Bash', { command: prefix + 'print(1)\nEOF' });
const h2 = hashToolCall('Bash', { command: prefix + 'print(2)\nEOF' });
assert.notStrictEqual(h1, h2);
})
)
passed++;
else failed++;
if ( if (
test('large edits diverging only after 2048 chars still hash differently', () => { test('large edits diverging only after 2048 chars still hash differently', () => {
// Shared prefix longer than the old HASH_INPUT_LIMIT (2048) truncation // Shared prefix longer than the old HASH_INPUT_LIMIT (2048) truncation