From 72fa1b4ecc4ba6714c095cd9c35cae2c695dfadb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 31 Aug 2026 15:54:13 -0700 Subject: [PATCH] [eric] router: always-on history pruning clears old tool-result bodies on the wire, newest kept verbatim --- backend/apps/agents/9router_gpt5_patch.js | 35 +++- backend/apps/agents/9router_history_prune.js | 128 ++++++++++++++ electron/historyPrune.test.js | 177 +++++++++++++++++++ 3 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 backend/apps/agents/9router_history_prune.js create mode 100644 electron/historyPrune.test.js diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 9d32cdf7..2fa3bcc3 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -98,7 +98,32 @@ const _http = require('http'); } catch (_) {} })(); -const TARGET_HOSTS = new Set(['api.openai.com']); +const TARGET_HOSTS = new Set(['api.openai.com', 'api.anthropic.com']); + +// ENG-418: continuous history pruning rides the same interceptor. Loaded lazily and fail-open so +// a missing or broken module costs the prune, never the request. +let _prune = null; +function historyPrune(bodyStr) { + try { + if (_prune === null) { + _prune = require(require('path').join(__dirname, '9router_history_prune.js')); + try { process.stderr.write('[history-prune] installed\n'); } catch (_) {} + } + return _prune.maybePrune(bodyStr); + } catch (_) { + _prune = { maybePrune: (b) => b }; + try { process.stderr.write('[history-prune] FAILED to load; requests pass through unpruned\n'); } catch (_) {} + return bodyStr; + } +} + +function transformBody(bodyStr, host) { + // Order matters only in that both must see valid JSON; each is a no-op off its own shape. + let out = bodyStr; + if (host === 'api.openai.com') out = maybeRewriteBody(out); + out = historyPrune(out); + return out; +} const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1'; function _log(msg) { @@ -169,6 +194,7 @@ function patchHttpRequest(orig) { if (!TARGET_HOSTS.has(host)) { return orig.apply(this, args); } + const targetHost = host; let req; try { req = orig.apply(this, args); } catch (e) { throw e; } @@ -217,7 +243,7 @@ function patchHttpRequest(orig) { let bodyStr = ''; if (isStringMode === true) bodyStr = chunks.join(''); else if (isStringMode === false) bodyStr = Buffer.concat(chunks).toString('utf8'); - const rewritten = maybeRewriteBody(bodyStr); + const rewritten = transformBody(bodyStr, targetHost); if (rewritten !== bodyStr) { const newBuf = Buffer.from(rewritten, 'utf8'); try { @@ -270,7 +296,7 @@ if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5 try { host = new URL(url).hostname.toLowerCase(); } catch (_) { return origFetch.call(this, input, init); } if (!TARGET_HOSTS.has(host)) return origFetch.call(this, input, init); if (init && typeof init.body === 'string') { - const rewritten = maybeRewriteBody(init.body); + const rewritten = transformBody(init.body, host); if (rewritten !== init.body) { const newInit = Object.assign({}, init, { body: rewritten }); const newLen = String(Buffer.byteLength(rewritten, 'utf8')); @@ -300,3 +326,6 @@ if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5 _log('fetch install failed: ' + (e && e.message ? e.message : String(e))); } } + +// Exported for the wiring test only; --require ignores exports. +module.exports = { transformBody }; diff --git a/backend/apps/agents/9router_history_prune.js b/backend/apps/agents/9router_history_prune.js new file mode 100644 index 00000000..16b54ae1 --- /dev/null +++ b/backend/apps/agents/9router_history_prune.js @@ -0,0 +1,128 @@ +// Always-on history pruning at the one wire we own (ENG-418 item 2, the hermes lift). +// +// The CLI resends the whole transcript every request and its own KEEP-RECENT microcompact is +// feature-gated off with no override, so old tool results pile up until the autocompact cliff +// (ENG-385: 100-292 tool-call chats dying at 80-160K). This module runs inside 9router via the +// same `node --require` patch that fixes GPT-5 max_tokens, and clears the BODIES of old +// tool_result blocks on the wire only: the CLI's transcript on disk stays complete, so nothing +// is destroyed, and a fresh session or a resume still has every byte. +// +// Rules, each one earned: +// - Newest KEEP_RECENT big results stay verbatim (the CLI's own microcompact keeps 5). +// - Small results are never touched: `git status` says "nothing to commit" in 200 bytes and that +// whole content IS the answer (the OmniRoute lesson: ratio bought by deleting answers is a sin). +// - Below ENGAGE_BYTES the body passes through BYTE-IDENTICAL, so short sessions keep their +// prompt-cache prefix and this module is provably a no-op where context is not scarce. +// - The stub is PASSIVE and carries no path and no instruction: an imperative inside tool output +// reads as an injection attempt and manufactures a security warning (drilled 2026-08-27). +// - Exact duplicates of a newer big result collapse even inside the keep window (hermes's rule). +// - Only tool_result CONTENT is replaced; ids, roles, ordering, assistant blocks and thinking +// signatures are untouched, so the API contract (every tool_use answered) cannot break. +// - Any parse trouble returns the body unchanged: fail-open, availability first. + +'use strict'; + +const KEEP_RECENT = 5; +const MIN_STUB_BYTES = 2000; +const ENGAGE_BYTES = 300000; + +const OFF = String(process.env.OSW_HISTORY_PRUNE || '').toLowerCase() === 'off'; +const DEBUG = process.env.OSW_HISTORY_PRUNE_DEBUG === '1'; + +function log(msg) { + try { process.stderr.write('[history-prune] ' + msg + '\n'); } catch (_) {} +} + +function blockText(block) { + // tool_result content is a string or a list of blocks; concatenate the text parts. + const c = block.content; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + let out = ''; + for (const p of c) { + if (p && p.type === 'text' && typeof p.text === 'string') out += p.text; + } + return out; + } + return ''; +} + +function blockHasImage(block) { + const c = block.content; + if (!Array.isArray(c)) return false; + for (const p of c) if (p && p.type === 'image') return true; + return false; +} + +function stubFor(block, reason) { + const n = blockText(block).length; + const img = blockHasImage(block); + let text; + if (reason === 'duplicate') { + text = '[Duplicate tool output: same content as a more recent result.]'; + } else if (img && n < MIN_STUB_BYTES) { + text = '[Old screenshot cleared by OpenSwarm to keep this long chat inside the model\'s context window.]'; + } else { + text = '[Old tool output cleared by OpenSwarm to keep this long chat inside the model\'s context window: ' + + n + ' characters from an earlier step' + (img ? ', plus a screenshot' : '') + '.]'; + } + return [{ type: 'text', text }]; +} + +// bodyStr -> { body: string, stats } ; body === input means untouched. +function pruneBody(bodyStr) { + const none = { body: bodyStr, stats: null }; + if (OFF) return none; + if (typeof bodyStr !== 'string' || bodyStr.length < ENGAGE_BYTES) return none; + let data; + try { data = JSON.parse(bodyStr); } catch (_) { return none; } + if (!data || !Array.isArray(data.messages)) return none; + + // Collect every prunable tool_result in transcript order. + const candidates = []; + for (const msg of data.messages) { + if (!msg || msg.role !== 'user' || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (!block || block.type !== 'tool_result') continue; + const size = blockText(block).length; + if (size >= MIN_STUB_BYTES || blockHasImage(block)) candidates.push(block); + } + } + if (candidates.length <= KEEP_RECENT) return none; + + const keepFrom = candidates.length - KEEP_RECENT; + const seenNewestFirst = new Set(); + // Walk newest-first so a duplicate always collapses toward its most recent copy. + for (let i = candidates.length - 1; i >= 0; i--) { + const b = candidates[i]; + const text = blockText(b); + if (i >= keepFrom) { + if (text) seenNewestFirst.add(text); + continue; + } + b.content = stubFor(b, seenNewestFirst.has(text) ? 'duplicate' : 'old'); + } + + let out; + try { out = JSON.stringify(data); } catch (_) { return none; } + const stats = { + stubbed: keepFrom, + kept: KEEP_RECENT, + savedBytes: bodyStr.length - out.length, + }; + return { body: out, stats }; +} + +function maybePrune(bodyStr) { + try { + const { body, stats } = pruneBody(bodyStr); + if (stats && DEBUG) { + log('engaged: stubbed=' + stats.stubbed + ' kept=' + stats.kept + ' saved=' + stats.savedBytes + 'B'); + } + return body; + } catch (_) { + return bodyStr; + } +} + +module.exports = { pruneBody, maybePrune, KEEP_RECENT, MIN_STUB_BYTES, ENGAGE_BYTES }; diff --git a/electron/historyPrune.test.js b/electron/historyPrune.test.js new file mode 100644 index 00000000..eef410d6 --- /dev/null +++ b/electron/historyPrune.test.js @@ -0,0 +1,177 @@ +// ENG-418: the wire-level history pruner. Tested from electron's node runner because the module is +// plain node JS; it ships from backend/apps/agents/ alongside the gpt5 patch that requires it. +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('path'); + +const MOD = path.join(__dirname, '..', 'backend', 'apps', 'agents', '9router_history_prune.js'); +const { pruneBody, KEEP_RECENT, MIN_STUB_BYTES, ENGAGE_BYTES } = require(MOD); + +function toolResult(id, text, extra) { + return Object.assign({ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }] }, extra); +} + +function bigBody(nResults, { size = 8000, pad = true } = {}) { + const messages = [{ role: 'user', content: 'walk the files' }]; + for (let i = 0; i < nResults; i++) { + messages.push({ role: 'assistant', content: [{ type: 'tool_use', id: 't' + i, name: 'Read', input: { file_path: '/f' + i } }] }); + messages.push({ role: 'user', content: [toolResult('t' + i, ('r' + i + ' ').padEnd(size, 'x'))] }); + } + const body = { model: 'claude-sonnet-4-6', messages }; + let s = JSON.stringify(body); + if (pad && s.length < ENGAGE_BYTES) { + // pad the FIRST user message so total size crosses the floor without adding results + messages[0].content = 'walk the files ' + 'p'.repeat(ENGAGE_BYTES - s.length); + s = JSON.stringify(body); + } + return s; +} + +test('below the floor the body passes through byte-identical', () => { + const s = bigBody(20, { pad: false }); + assert.ok(s.length < ENGAGE_BYTES, 'fixture must sit under the floor'); + const { body, stats } = pruneBody(s); + assert.strictEqual(body, s); + assert.strictEqual(stats, null); +}); + +test('above the floor old big results are stubbed and the newest KEEP_RECENT stay verbatim', () => { + const s = bigBody(12); + const { body, stats } = pruneBody(s); + assert.ok(stats, 'must engage'); + assert.strictEqual(stats.stubbed, 12 - KEEP_RECENT); + const d = JSON.parse(body); + const results = []; + for (const m of d.messages) { + if (m.role !== 'user' || !Array.isArray(m.content)) continue; + for (const b of m.content) if (b.type === 'tool_result') results.push(b); + } + assert.strictEqual(results.length, 12, 'every tool_use keeps its tool_result'); + for (let i = 0; i < 12 - KEEP_RECENT; i++) { + assert.match(results[i].content[0].text, /cleared by OpenSwarm/, 'old result ' + i); + assert.strictEqual(results[i].tool_use_id, 't' + i, 'id survives'); + } + for (let i = 12 - KEEP_RECENT; i < 12; i++) { + assert.ok(results[i].content[0].text.startsWith('r' + i + ' '), 'recent result kept verbatim'); + } +}); + +test('small results are never touched at any age (the git-status rule)', () => { + const messages = [{ role: 'user', content: 'go' }]; + for (let i = 0; i < 20; i++) { + messages.push({ role: 'user', content: [toolResult('s' + i, 'nothing to commit ' + i)] }); + } + for (let i = 0; i < 8; i++) { + messages.push({ role: 'user', content: [toolResult('b' + i, 'big'.padEnd(9000, 'y'))] }); + } + const body = { model: 'm', messages }; + let s = JSON.stringify(body); + messages[0].content = 'go' + 'p'.repeat(Math.max(0, ENGAGE_BYTES - s.length)); + s = JSON.stringify(body); + const { body: out } = pruneBody(s); + const d = JSON.parse(out); + let untouchedSmall = 0; + for (const m of d.messages) { + if (!Array.isArray(m.content)) continue; + for (const b of m.content) { + if (b.type === 'tool_result' && /^nothing to commit/.test(b.content[0].text)) untouchedSmall++; + } + } + assert.strictEqual(untouchedSmall, 20); +}); + +test('an exact duplicate of a kept result collapses to the duplicate stub', () => { + const dupText = 'same bytes '.padEnd(6000, 'z'); + const messages = [{ role: 'user', content: 'go' }]; + messages.push({ role: 'user', content: [toolResult('old', dupText)] }); + for (let i = 0; i < KEEP_RECENT + 2; i++) { + messages.push({ role: 'user', content: [toolResult('f' + i, 'fill '.padEnd(6000, 'w') + i)] }); + } + messages.push({ role: 'user', content: [toolResult('new', dupText)] }); + const body = { model: 'm', messages }; + let s = JSON.stringify(body); + messages[0].content = 'go' + 'p'.repeat(Math.max(0, ENGAGE_BYTES - s.length)); + s = JSON.stringify(body); + const d = JSON.parse(pruneBody(s).body); + const first = d.messages[1].content[0]; + assert.match(first.content[0].text, /Duplicate tool output/); + const last = d.messages[d.messages.length - 1].content[0]; + assert.strictEqual(last.content[0].text, dupText, 'the newest copy is the one that survives'); +}); + +test('old screenshots are cleared, recent ones kept', () => { + const messages = [{ role: 'user', content: 'go' }]; + const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'A'.repeat(50000) } }; + for (let i = 0; i < KEEP_RECENT + 3; i++) { + messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'i' + i, content: [img] }] }); + } + const s = JSON.stringify({ model: 'm', messages }); + assert.ok(s.length >= ENGAGE_BYTES, 'screenshots alone cross the floor'); + const d = JSON.parse(pruneBody(s).body); + const results = d.messages.slice(1).map((m) => m.content[0]); + for (let i = 0; i < 3; i++) assert.match(results[i].content[0].text, /screenshot cleared/); + for (let i = 3; i < results.length; i++) assert.strictEqual(results[i].content[0].type, 'image'); +}); + +test('the stub is passive: no imperative verbs, no filesystem path', () => { + const s = bigBody(12); + const d = JSON.parse(pruneBody(s).body); + const stub = d.messages[2].content[0].content[0].text; + assert.doesNotMatch(stub, /\b(read|open|run|fetch|load|see)\b/i, 'an imperative in tool output reads as injection'); + assert.doesNotMatch(stub, /\//, 'a path is an affordance the model must not be offered'); +}); + +test('assistant blocks, system and thinking are untouched, and pruning is deterministic', () => { + const s = bigBody(12); + const one = pruneBody(s).body; + const two = pruneBody(one); + assert.strictEqual(two.body, one, 'pruning an already-pruned body changes nothing'); + const dIn = JSON.parse(s), dOut = JSON.parse(one); + assert.deepStrictEqual( + dOut.messages.filter((m) => m.role === 'assistant'), + dIn.messages.filter((m) => m.role === 'assistant'), + ); +}); + +test('malformed JSON and OpenAI-shaped bodies pass through untouched', () => { + const junk = 'x'.repeat(ENGAGE_BYTES) + '{not json'; + assert.strictEqual(pruneBody(junk).body, junk); + const openai = JSON.stringify({ model: 'gpt-5', messages: [{ role: 'tool', content: 'k'.repeat(ENGAGE_BYTES) }] }); + const out = pruneBody(openai).body; + assert.strictEqual(out, openai, 'role:tool (openai shape) has no tool_result blocks; untouched'); +}); + +test('OSW_HISTORY_PRUNE=off is honored at module load', () => { + // the flag is read at require time; spawn a child to prove the seam works end to end + const { execFileSync } = require('child_process'); + const script = ` + const { pruneBody } = require(${JSON.stringify(MOD)}); + const msgs = [{ role: 'user', content: 'p'.repeat(${ENGAGE_BYTES}) }]; + for (let i = 0; i < 12; i++) msgs.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't'+i, content: [{ type:'text', text: 'x'.repeat(8000) }] }] }); + const s = JSON.stringify({ model: 'm', messages: msgs }); + process.stdout.write(String(pruneBody(s).body === s)); + `; + const out = execFileSync(process.execPath, ['-e', script], { env: Object.assign({}, process.env, { OSW_HISTORY_PRUNE: 'off' }) }).toString(); + assert.strictEqual(out, 'true'); +}); + +test('WIRING: the gpt5 patch itself routes anthropic bodies through the pruner', () => { + // Composition test: the interceptor plumbing (write/end buffering, Content-Length) is already + // proven by the shipped gpt5 rewrite; what a regression would break is the transform chain, so + // that is what this drives. Unwiring historyPrune() from transformBody turns this red. + const PATCH = require('path').join(__dirname, '..', 'backend', 'apps', 'agents', '9router_gpt5_patch.js'); + const { transformBody } = require(PATCH); + const msgs = [{ role: 'user', content: 'p'.repeat(ENGAGE_BYTES) }]; + // each body unique, or the dedup rule fires instead and the 'cleared' count reads 0 (found live) + for (let i = 0; i < 12; i++) msgs.push({ role: 'user', content: [toolResult('t' + i, ('u' + i + ' ').padEnd(8000, 'x'))] }); + const s = JSON.stringify({ model: 'claude-sonnet-4-6', messages: msgs }); + const out = transformBody(s, 'api.anthropic.com'); + const d = JSON.parse(out); + const stubbed = d.messages.filter((m) => Array.isArray(m.content) && m.content.some( + (b) => b.type === 'tool_result' && Array.isArray(b.content) && /cleared by OpenSwarm/.test(b.content[0].text || '') + )).length; + assert.strictEqual(stubbed, 12 - KEEP_RECENT); + // and the gpt5 rewrite still fires on its own host + const g = JSON.stringify({ model: 'gpt-5', max_tokens: 100, messages: [] }); + assert.match(transformBody(g, 'api.openai.com'), /max_completion_tokens/); +});