upd
This commit is contained in:
152
src/compact.js
152
src/compact.js
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user