This commit is contained in:
seb
2026-09-23 00:50:44 +02:00
parent 73e7f7e7f8
commit 5aab24998d
8 changed files with 499 additions and 16 deletions

View File

@@ -10,8 +10,18 @@
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
* call in the transcript so its long text fields keep only a head/tail excerpt,
* with a marker noting how many lines were elided.
*
* `invalidateStaleReadResults` attacks the same problem from the other side:
* a `read_file` result carries a whole file body, and once a later `patch`
* writes that file the body no longer matches what is on disk. Keeping it is
* both a token waste and a correctness hazard (the model may reason from stale
* contents). The stale result is replaced by a short "file was overwritten
* meanwhile" marker that tells the model to re-read if it needs the file.
*/
import path from 'path';
import { ROOT_DIR } from './config.js';
// Lines kept from the top and bottom of each long text field.
const HEAD_LINES = 5;
const TAIL_LINES = 5;
@@ -92,3 +102,145 @@ export function compactPatchToolCalls(messages) {
}
return compacted;
}
// ─── Stale read_file results ────────────────────────────────────────────────────
// Marker carried by a replaced read_file result. Detected verbatim so repeated
// passes over the transcript stay idempotent (an already-invalidated result is
// left as-is instead of being wrapped again).
const STALE_MARKER = 'file was overwritten meanwhile';
// Explanatory text carried by the replacement. Like the omission marker above,
// this is written by the memory-management system, not by the model — the note
// says so explicitly so the model does not mistake it for its own earlier
// output or for real file contents.
const STALE_NOTE =
`${STALE_MARKER} — stale contents elided by the memory management system ` +
'to save tokens; re-read with read_file for the current contents';
// `applyOperations` tags each change it makes as either an overwrite or a
// search/replace; both mean the file body a prior read returned is now stale.
const APPLIED_RE = /^(?:CREATE\/WRITE|PATCH in) (.+)$/;
/**
* Canonical key for a workspace-relative path, so a path the model wrote as
* "src/x.js" matches one written as "./src/x.js" or with different separators.
*/
function canonicalPath(p) {
if (typeof p !== 'string' || !p) return null;
try {
return path.resolve(ROOT_DIR, p);
} catch {
return p;
}
}
/** The replacement payload handed to the model in place of a stale read. */
function staleReadResult(filename) {
return JSON.stringify({ filename, note: STALE_NOTE });
}
/**
* Extract the files a `patch` result actually wrote. Both successful results
* (`applied`) and partially-failed ones (`appliedSoFar`) list the mutations, so
* files that were never touched are correctly left alone. Returns canonical
* path keys; an unparseable result yields an empty list.
*/
function filesWrittenByPatch(resultContent) {
let res = resultContent;
if (typeof res === 'string') {
try {
res = JSON.parse(res);
} catch {
return [];
}
}
if (!res || typeof res !== 'object') return [];
const applied = Array.isArray(res.applied)
? res.applied
: Array.isArray(res.appliedSoFar)
? res.appliedSoFar
: [];
const files = new Set();
for (const entry of applied) {
if (typeof entry !== 'string') continue;
const hit = APPLIED_RE.exec(entry);
if (hit) {
const key = canonicalPath(hit[1]);
if (key) files.add(key);
}
}
return [...files];
}
/**
* Replace `read_file` tool results in `messages` with a short "file was
* overwritten meanwhile" marker whenever a later `patch` wrote the same file.
*
* A read result is stale only if the file changed *after* it was read, so we
* compare transcript positions: a read is invalidated when a successful patch
* write sits at a later index. Reads taken after their last patch (which still
* reflect disk) are left untouched, as are reads for files no patch ever wrote.
*
* The rewrite only fires when it strictly shrinks the payload, so a tiny read
* result is never traded for a longer marker. Repeated passes are idempotent.
* Returns the number of results invalidated.
*/
export function invalidateStaleReadResults(messages) {
if (!Array.isArray(messages)) return 0;
// Index every tool call so a tool result can be mapped back to (name, args).
const callInfo = new Map();
for (const m of messages) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
for (const tc of m.tool_calls) {
if (!tc || !tc.id) continue;
let args = {};
try {
args = JSON.parse(tc.function?.arguments || '{}');
} catch {
// Malformed arguments: the call stays indexed but argless.
}
callInfo.set(tc.id, { name: tc.function?.name, args });
}
}
// Every place a patch wrote a file, keyed by transcript position.
const writes = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (!m || m.role !== 'tool') continue;
if (callInfo.get(m.tool_call_id)?.name !== 'patch') continue;
const files = filesWrittenByPatch(m.content);
if (files.length) writes.push({ index: i, files });
}
if (writes.length === 0) return 0;
let invalidated = 0;
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (!m || m.role !== 'tool') continue;
const info = callInfo.get(m.tool_call_id);
if (info?.name !== 'read_file') continue;
const key = canonicalPath(info.args?.filename);
if (!key) continue;
const content = typeof m.content === 'string' ? m.content : '';
// Already replaced on an earlier pass — leave it alone.
if (content.includes(STALE_MARKER)) continue;
// Stale only if some patch wrote this file after the read happened.
const stale = writes.some((w) => w.index > i && w.files.includes(key));
if (!stale) continue;
const replacement = staleReadResult(info.args.filename);
// Never grow the payload: only swap when the marker is actually smaller.
if (replacement.length >= content.length) continue;
m.content = replacement;
invalidated++;
}
return invalidated;
}