parse-run.mjs 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. #!/usr/bin/env node
  2. // Parse Claude Code stream-json run log(s): tool-call sequence, token usage, and
  3. // RESIDUAL CONTEXT OCCUPANCY — how many tokens of the context window each tool
  4. // family's responses still occupy when the run ends.
  5. //
  6. // Usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--envelope] [--answer <glob>]...
  7. // Multiple files = one multi-turn session's segments, IN ORDER (run-all.sh
  8. // writes run-<label>.jsonl, run-<label>.t2.jsonl, … for a `Q1||Q2||Q3` set).
  9. // `--resume` does not replay prior messages, so the segments concatenate
  10. // cleanly and token accounting carries across the boundary.
  11. //
  12. // Every run also reports EXPLORE SUFFICIENCY — each codegraph_explore call
  13. // bucketed by what the agent did next (see classifySufficiency).
  14. //
  15. // `--envelope` additionally reports how the codegraph_explore responses were
  16. // DIVIDED across files — the per-file share of the source envelope (#1500).
  17. // `--answer <glob>` (repeatable, implies --envelope) marks the files that
  18. // actually answer the question and reports their combined share: bar 2 of the
  19. // CG-1/CG-22 allocation gate. See formatEnvelope for why it parses the
  20. // rendered markdown rather than the CG-4 diagnostic sidecar.
  21. //
  22. // ---------------------------------------------------------------------------
  23. // Why occupancy, and how it's measured
  24. // ---------------------------------------------------------------------------
  25. // A single-question A/B reports cost/tokens/time/tool-calls for ONE answer. It
  26. // cannot see what issue #1500 measured: a tool response stays in the window for
  27. // everything that follows, so it is charged against every later turn's headroom.
  28. // That is a per-session cost our single-question runs structurally miss.
  29. //
  30. // Tokens are MEASURED, not estimated at bytes/4. For assistant request k,
  31. // ctx_k = usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens
  32. // is the exact token count of that request's whole prompt. So
  33. // gap_k = ctx_k - ctx_{k-1}
  34. // is exactly the tokens appended since the previous request: the previous
  35. // assistant output (thinking + text + tool_use JSON) plus the tool_results and
  36. // user text that followed it. We split gap_k across those blocks in proportion
  37. // to their characters, which attributes each tool_result its measured share.
  38. // (Measured on real runs, explore output lands near 2.3 chars/token — bytes/4
  39. // under-counts it by ~40%, which is why the estimate isn't good enough.)
  40. //
  41. // Two traps this file works around, both verified against real logs:
  42. // * Claude Code emits ONE assistant event PER CONTENT BLOCK, all carrying the
  43. // same message.id and the same `usage`. Summing usage per event double-counts
  44. // every turn that emits both thinking and a tool_use — dedupe by message.id.
  45. // * The streamed `output_tokens` is a partial snapshot (observed `out=2` on a
  46. // turn that really generated ~1100). Never trust it; the char-proportional
  47. // split doesn't need it.
  48. //
  49. // Residual ≠ contributed. Content leaves the window two ways, and both are
  50. // tracked: a `compact_boundary` system event (everything prior is replaced by a
  51. // summary) and micro-compaction (ctx drops mid-run — oldest tool results are
  52. // dropped first, so eviction is applied FIFO).
  53. import { readFileSync } from 'fs';
  54. import { pathToFileURL } from 'url';
  55. // Nominal window for the share-of-window column. Override for a [1m] context.
  56. const WINDOW_TOKENS = Number(process.env.CG_WINDOW_TOKENS || 200_000);
  57. const CHARS_PER_TOKEN_FALLBACK = 3.0;
  58. /** Which tool family a tool_use belongs to. */
  59. function familyOf(name) {
  60. if (/codegraph/.test(name)) return 'codegraph';
  61. if (name === 'Read' || name === 'NotebookRead') return 'read';
  62. if (name === 'Grep' || name === 'Glob') return 'search';
  63. if (name === 'Bash' || name === 'BashOutput') return 'bash';
  64. return 'other';
  65. }
  66. const FAMILIES = ['codegraph', 'read', 'search', 'bash', 'other'];
  67. // The without-arm's way of getting the same bytes: reading and searching files.
  68. const FILE_ACCESS = ['read', 'search', 'bash'];
  69. // A Bash command that INVOKES the codegraph CLI, in any command position and by
  70. // any path. Mentions are not invocations: `grep codegraph src/`, `ls .codegraph`
  71. // and `which codegraph` all pass. Kept in step with run-all.sh's blocking hook.
  72. const CG_CLI_RE = /(^|[;&|(]|&&|\|\||\$\(|`)\s*(?:[A-Za-z_]\w*=\S*\s+)*[\w./~-]*codegraph(\s|$)/;
  73. const textOf = (content) =>
  74. Array.isArray(content) ? content.map((c) => c.text ?? (typeof c === 'string' ? c : JSON.stringify(c))).join('')
  75. : typeof content === 'string' ? content
  76. : content == null ? '' : JSON.stringify(content);
  77. /** Characters an assistant content block occupies once it is back in the prompt. */
  78. function assistantBlockChars(b) {
  79. if (b.type === 'text') return (b.text || '').length;
  80. if (b.type === 'thinking') return (b.thinking || '').length;
  81. if (b.type === 'tool_use') return JSON.stringify(b.input ?? {}).length + (b.name || '').length;
  82. return JSON.stringify(b).length;
  83. }
  84. /**
  85. * Parse one session (its segment files, in order) into tool + occupancy stats.
  86. * Exported so parse-bench-readme.mjs can aggregate without duplicating any of
  87. * this — deliberately NOT a separate module file: a new scripts/agent-eval/*.mjs
  88. * scores into the self-query eval fixture's own corpus and moves its numbers.
  89. */
  90. export function parseSession(files) {
  91. const events = [];
  92. for (const f of files) {
  93. for (const line of readFileSync(f, 'utf8').split('\n')) {
  94. if (!line) continue;
  95. try { events.push(JSON.parse(line)); } catch { /* partial line */ }
  96. }
  97. }
  98. const toolCalls = []; // display sequence
  99. const nameById = new Map(); // tool_use_id -> tool name
  100. const cliById = new Set(); // tool_use_ids that tried to run the codegraph CLI
  101. const counts = {}; // tool name -> calls
  102. // Attempts vs successes: run-all.sh's hook DENIES CLI invocations, and a
  103. // denied attempt puts no codegraph output in the window. Only a call that
  104. // actually returned content contaminates the arm.
  105. let initTools = null, result = null, raced = false, cliCalls = 0, cliContaminated = 0;
  106. const results = []; // one `result` event per session segment (multi-turn)
  107. let compactions = 0;
  108. // Raw codegraph_explore response text, in call order. Feeds the envelope view
  109. // (see formatEnvelope) — kept here rather than re-parsed from the log later so
  110. // a multi-segment session's responses stay in one ordered list.
  111. const exploreTexts = [];
  112. // A timeline of everything appended to the context, in order. `req` entries
  113. // are assistant requests (carrying that request's ctx); `add` entries are
  114. // characters appended (assistant output blocks, tool results, user text).
  115. const timeline = [];
  116. const seenMsgIds = new Set();
  117. for (const ev of events) {
  118. if (ev.type === 'system' && ev.subtype === 'init') {
  119. initTools = (ev.tools || []).filter((t) => /codegraph/.test(t));
  120. }
  121. if (ev.type === 'system' && (ev.subtype === 'compact_boundary' || ev.subtype === 'compaction')) {
  122. compactions++;
  123. timeline.push({ kind: 'compact' });
  124. }
  125. if (ev.type === 'assistant' && ev.message) {
  126. const id = ev.message.id;
  127. // One event per content block, same id + same usage: count usage once,
  128. // but take the content blocks from every event that carries the id.
  129. if (id && !seenMsgIds.has(id)) {
  130. seenMsgIds.add(id);
  131. const u = ev.message.usage || {};
  132. const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
  133. timeline.push({ kind: 'req', ctx, out: u.output_tokens || 0 });
  134. }
  135. for (const b of ev.message.content || []) {
  136. timeline.push({ kind: 'add', family: null, chars: assistantBlockChars(b) });
  137. if (b.type === 'tool_use') {
  138. nameById.set(b.id, b.name);
  139. counts[b.name] = (counts[b.name] || 0) + 1;
  140. let detail = '';
  141. if (b.name === 'Task') detail = ` [subagent_type=${b.input?.subagent_type ?? '?'}] ${(b.input?.description ?? '').slice(0, 40)}`;
  142. else if (/codegraph/.test(b.name)) detail = ` ${JSON.stringify(b.input?.query ?? b.input?.task ?? b.input?.symbol ?? '').slice(0, 60)}`;
  143. else if (b.name === 'Bash') {
  144. detail = ` ${(b.input?.command ?? '').slice(0, 50)}`;
  145. // An arm with no codegraph MCP can still shell out to the CLI — the
  146. // target repo carries the .codegraph/ index and the binary is on
  147. // PATH. That silently turns a "without" arm into codegraph-over-CLI.
  148. if (CG_CLI_RE.test(b.input?.command ?? '')) { cliCalls++; cliById.add(b.id); }
  149. }
  150. else if (b.name === 'Read') detail = ` ${(b.input?.file_path ?? '').split('/').slice(-1)[0]}`;
  151. toolCalls.push(`${b.name}${detail}`);
  152. }
  153. }
  154. }
  155. if (ev.type === 'user' && ev.message) {
  156. const content = ev.message.content;
  157. if (Array.isArray(content)) {
  158. for (const b of content) {
  159. if (b.type === 'tool_result') {
  160. const t = textOf(b.content);
  161. // MCP cold-start race: the agent fired before `serve --mcp` had
  162. // registered its tools, so it floundered into grep/Read. That
  163. // measures startup latency, not steady-state value — flag it.
  164. if (/No such tool available/.test(t)) raced = true;
  165. // A CLI attempt that came back an error was blocked (by the hook, or
  166. // by the binary being genuinely absent) and put nothing in context.
  167. if (cliById.has(b.tool_use_id) && !b.is_error) cliContaminated++;
  168. const name = nameById.get(b.tool_use_id) || '';
  169. if (/codegraph_explore/.test(name) && !b.is_error) exploreTexts.push(t);
  170. timeline.push({ kind: 'add', family: familyOf(name), chars: t.length, tool: name });
  171. } else {
  172. timeline.push({ kind: 'add', family: null, chars: textOf([b]).length });
  173. }
  174. }
  175. } else if (typeof content === 'string') {
  176. timeline.push({ kind: 'add', family: null, chars: content.length });
  177. }
  178. }
  179. if (ev.type === 'result') { result = ev; results.push(ev); }
  180. }
  181. // ---- Pass 1: chars/token, calibrated on tool-result-dominated gaps. ------
  182. // Splitting a gap in proportion to characters over-attributes to tool results
  183. // whenever the assistant's own output is under-represented in the transcript
  184. // (redacted/empty thinking blocks are the common case — a gap whose only
  185. // visible chars were a 73-char tool_result charged it the whole 830-token
  186. // delta, 5.5 tok/char). So calibrate the ratio on gaps that are ≥80% tool
  187. // result by characters, then price every result at that ratio.
  188. const reqIdx = timeline.map((t, i) => (t.kind === 'req' ? i : -1)).filter((i) => i >= 0);
  189. const gaps = [];
  190. for (let k = 1; k < reqIdx.length; k++) {
  191. const prev = timeline[reqIdx[k - 1]], cur = timeline[reqIdx[k]];
  192. let chars = 0, toolChars = 0, compacted = false;
  193. const byFamily = {};
  194. for (let i = reqIdx[k - 1] + 1; i < reqIdx[k]; i++) {
  195. const t = timeline[i];
  196. if (t.kind === 'compact') { compacted = true; continue; }
  197. if (t.kind !== 'add') continue;
  198. chars += t.chars;
  199. if (t.family) { toolChars += t.chars; byFamily[t.family] = (byFamily[t.family] || 0) + t.chars; }
  200. }
  201. gaps.push({ delta: cur.ctx - prev.ctx, chars, toolChars, byFamily, compacted });
  202. }
  203. const clean = gaps.filter((g) => !g.compacted && g.delta > 0 && g.chars > 500 && g.toolChars / g.chars >= 0.8);
  204. // A gap where the window also SHED content has a delta far below what was
  205. // added, which reads as absurdly dense text and would drag the whole run's
  206. // ratio with it. Shedding can only push a gap's chars/token UP, so take the
  207. // lower median as the honest centre and drop anything well above it, then
  208. // pool the survivors. (On runs that never shed, every ratio is within a few
  209. // percent of the others and this changes nothing.)
  210. const ratios = clean.map((g) => g.toolChars / g.delta).sort((a, b) => a - b);
  211. const lowerMedian = ratios.length ? ratios[Math.floor((ratios.length - 1) / 2)] : 0;
  212. let sumD = 0, sumC = 0;
  213. for (const g of clean) {
  214. if (lowerMedian > 0 && g.toolChars / g.delta > lowerMedian * 1.5) continue; // shed
  215. sumD += g.delta; sumC += g.toolChars;
  216. }
  217. if (sumD === 0) { // no clean gap — fall back to every growing gap, all chars
  218. for (const g of gaps) if (!g.compacted && g.delta > 0 && g.chars > 0) { sumD += g.delta; sumC += g.chars; }
  219. }
  220. const charsPerToken = sumD > 0 ? sumC / sumD : CHARS_PER_TOKEN_FALLBACK;
  221. const calibrated = sumD > 0;
  222. // How far a single result's token density strays from the run-level ratio.
  223. // On a gap that is almost entirely one tool result, `delta` IS that result's
  224. // token count, so |chars/ratio - delta| / delta is the attribution error for
  225. // that result. The median over such gaps is the metric's real error bar.
  226. const errs = [];
  227. for (const g of gaps) {
  228. if (g.compacted || g.delta <= 0 || g.chars <= 500) continue;
  229. if (g.toolChars / g.chars < 0.95) continue;
  230. errs.push(Math.abs(g.toolChars / charsPerToken - g.delta) / g.delta);
  231. }
  232. errs.sort((a, b) => a - b);
  233. const dispersion = errs.length ? errs[(errs.length - 1) >> 1] : null;
  234. // ---- Pass 2: attribute gap tokens, then apply evictions FIFO. ------------
  235. const contributed = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  236. const resultChars = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  237. const resultCount = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  238. for (const t of timeline) if (t.kind === 'add' && t.family) { resultChars[t.family] += t.chars; resultCount[t.family]++; }
  239. let queue = []; // resident contributions, oldest first
  240. let evicted = 0;
  241. const evict = (tokens) => {
  242. let left = tokens;
  243. while (left > 0 && queue.length) {
  244. const head = queue[0];
  245. if (head.tokens <= left) { left -= head.tokens; evicted += head.tokens; queue.shift(); }
  246. else { head.tokens -= left; evicted += left; left = 0; }
  247. }
  248. };
  249. for (const g of gaps) {
  250. if (g.compacted) {
  251. // Everything before the boundary is gone; the summary replaces it.
  252. evicted += queue.reduce((s, q) => s + q.tokens, 0);
  253. queue = [];
  254. }
  255. let toolTokens = 0;
  256. for (const [fam, ch] of Object.entries(g.byFamily)) {
  257. const tok = ch / charsPerToken;
  258. toolTokens += tok;
  259. contributed[fam] += tok;
  260. queue.push({ family: fam, tokens: tok });
  261. }
  262. // The gap grew by `delta`; the tool results account for `toolTokens` of it.
  263. // A shortfall means the window also shed content — micro-compaction drops
  264. // the OLDEST tool results first, so evict FIFO. The tolerance keeps
  265. // attribution noise (a run-level ratio priced against one gap's delta,
  266. // typically ±2%) from reading as an eviction; real shedding is thousands.
  267. const shortfall = toolTokens - g.delta;
  268. if (!g.compacted && shortfall > Math.max(200, toolTokens * 0.05)) evict(shortfall);
  269. }
  270. const residual = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  271. for (const q of queue) residual[q.family] += q.tokens;
  272. const ctxFinal = reqIdx.length ? timeline[reqIdx[reqIdx.length - 1]].ctx : 0;
  273. // The FIRST request's prompt is system + tool schemas + the question, before
  274. // any tool has answered. Differencing the arms' ctxBase prices codegraph's
  275. // FIXED occupancy — its tool schema and MCP `initialize` instructions — which
  276. // it pays whether or not the agent ever calls it.
  277. const ctxBase = reqIdx.length ? timeline[reqIdx[0]].ctx : 0;
  278. // Multi-turn: duration/cost/tokens are per-segment, so sum them. `result.usage`
  279. // is cumulative WITHIN a segment (verified: its in+cache+out equals the sum of
  280. // that segment's per-request prompts), so summing segments is correct and does
  281. // NOT double-count. It is a "tokens processed" figure — every request re-counts
  282. // the whole prefix — which is exactly why it can't answer the occupancy question.
  283. const sumUsage = (k) => results.reduce((s, r) => s + (r.usage?.[k] || 0), 0);
  284. const processed = sumUsage('input_tokens') + sumUsage('cache_read_input_tokens')
  285. + sumUsage('cache_creation_input_tokens') + sumUsage('output_tokens');
  286. return {
  287. files, toolCalls, counts, initTools, result, results, raced, cliCalls, cliContaminated,
  288. exploreTexts,
  289. // What the agent did after each explore — the free sufficiency signal (CG-8).
  290. sufficiency: classifySufficiency(events),
  291. ok: results.length > 0 && results.every((r) => r.subtype === 'success'),
  292. turns: reqIdx.length,
  293. tools: toolCalls.filter((t) => !t.startsWith('ToolSearch')).length,
  294. reads: counts.Read || 0,
  295. grep: (counts.Grep || 0) + (counts.Glob || 0),
  296. cg: Object.entries(counts).filter(([n]) => /codegraph/.test(n)).reduce((s, [, v]) => s + v, 0),
  297. dur: results.reduce((s, r) => s + (r.duration_ms || 0), 0) / 1000,
  298. cost: results.reduce((s, r) => s + (r.total_cost_usd || 0), 0),
  299. processed,
  300. occupancy: {
  301. ctxFinal, ctxBase, windowTokens: WINDOW_TOKENS,
  302. charsPerToken, calibrated, compactions, dispersion, evicted: Math.round(evicted),
  303. residual: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(residual[f])])),
  304. contributed: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(contributed[f])])),
  305. chars: resultChars, results: resultCount,
  306. residualFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + residual[f], 0)),
  307. contributedFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + contributed[f], 0)),
  308. charsFileAccess: FILE_ACCESS.reduce((s, f) => s + resultChars[f], 0),
  309. },
  310. };
  311. }
  312. /** The occupancy block, as printed under a run and reused by the aggregator. */
  313. export function formatOccupancy(s, indent = ' ') {
  314. const o = s.occupancy;
  315. const n = (x) => x.toLocaleString('en-US');
  316. const pctCtx = (t) => (o.ctxFinal > 0 ? ((t / o.ctxFinal) * 100).toFixed(1) : '0.0');
  317. const pctWin = (t) => ((t / o.windowTokens) * 100).toFixed(1);
  318. const rows = [];
  319. const row = (label, tok, chars, results) => rows.push(
  320. `${indent} ${label.padEnd(18)}${(n(tok) + ' tok').padStart(12)} ${(pctCtx(tok) + '%').padStart(6)} of ctx ` +
  321. `${(pctWin(tok) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k win` +
  322. (chars !== undefined ? ` (${n(chars)} chars, ${results} result${results === 1 ? '' : 's'})` : '')
  323. );
  324. const out = [`${indent}Residual context occupancy at end of run:`];
  325. out.push(`${indent} ${'final context'.padEnd(18)}${(n(o.ctxFinal) + ' tok').padStart(12)} ${(pctWin(o.ctxFinal) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k window`);
  326. row('codegraph', o.residual.codegraph, o.chars.codegraph, o.results.codegraph);
  327. row('Read', o.residual.read, o.chars.read, o.results.read);
  328. row('Grep/Glob', o.residual.search, o.chars.search, o.results.search);
  329. row('Bash', o.residual.bash, o.chars.bash, o.results.bash);
  330. row('→ file-access', o.residualFileAccess, o.charsFileAccess,
  331. o.results.read + o.results.search + o.results.bash);
  332. row('other tools', o.residual.other, o.chars.other, o.results.other);
  333. const toolTotal = Object.values(o.residual).reduce((a, b) => a + b, 0);
  334. row('base (prompt+prose)', Math.max(0, o.ctxFinal - toolTotal));
  335. out.push(`${indent} ${' of which fixed'.padEnd(18)}${(n(o.ctxBase) + ' tok').padStart(12)} system + tool schemas + question, before any tool answered`);
  336. out.push(...rows);
  337. const dropped = o.contributed.codegraph + o.contributedFileAccess + o.contributed.other
  338. - (o.residual.codegraph + o.residualFileAccess + o.residual.other);
  339. out.push(
  340. `${indent} measure: ${o.charsPerToken.toFixed(2)} chars/tok ${o.calibrated ? 'measured' : '(FALLBACK — no clean gap to calibrate on)'}` +
  341. (o.dispersion !== null ? ` ±${(o.dispersion * 100).toFixed(1)}%` : '') +
  342. ` · turns ${s.turns} · compactions ${o.compactions}` +
  343. (o.evicted > 0 || dropped > 1 ? ` · evicted ${n(o.evicted)} tok` : '')
  344. );
  345. return out.join('\n');
  346. }
  347. /**
  348. * How the codegraph_explore responses the agent received were DIVIDED across
  349. * files — the per-file share of the source envelope (#1500 / epic CG-1).
  350. *
  351. * Parsed out of the RENDERED MARKDOWN, not the CG-4 diagnostic sidecar: the
  352. * sidecar only exists on a post-CG-4 build, so it cannot measure a baseline arm.
  353. * The markdown parse is the only instrument that measures both arms of a
  354. * new-vs-baseline A/B the same way.
  355. *
  356. * `answerGlobs` marks the files that actually answer the question; the summary
  357. * reports their combined share, which is bar 2 of the CG-1/CG-22 gate.
  358. */
  359. export function formatEnvelope(exploreTexts, answerGlobs = [], indent = ' ') {
  360. // `tools/cache/**` -> /^tools\/cache\/.*$/ . Same semantics as probe-allocation.
  361. // The `**` sentinel is written as an escape, never a literal NUL byte — a raw
  362. // one makes git treat this whole script as binary and costs every future diff.
  363. const glob2re = (glob) => {
  364. const S = '\\u0000';
  365. const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
  366. .replace(/\*\*/g, S).replace(/\*/g, '[^/]*').replaceAll(S, '.*');
  367. return new RegExp(`^${body}$`);
  368. };
  369. const answerRes = answerGlobs.map(glob2re);
  370. const isAnswer = (p) => answerRes.some((re) => re.test(p));
  371. // Each rendered file section starts with **`path`** — its bytes run to the next
  372. // such header (or to the trailing guidance quote). Share is over the sum of the
  373. // sections, i.e. of the source envelope the allocator divides.
  374. const pooled = new Map();
  375. let envelope = 0;
  376. for (const text of exploreTexts) {
  377. const re = /^\*\*`([^`]+)`\*\*/gm;
  378. const marks = [];
  379. let m;
  380. while ((m = re.exec(text)) !== null) marks.push({ path: m[1], at: m.index });
  381. if (!marks.length) continue;
  382. const tail = text.indexOf('\n> ', marks[marks.length - 1].at);
  383. const end = tail === -1 ? text.length : tail;
  384. marks.forEach((mark, i) => {
  385. const chars = (i + 1 < marks.length ? marks[i + 1].at : end) - mark.at;
  386. pooled.set(mark.path, (pooled.get(mark.path) ?? 0) + chars);
  387. envelope += chars;
  388. });
  389. }
  390. const ranked = [...pooled.entries()]
  391. .map(([path, chars]) => ({ path, chars, share: envelope ? chars / envelope : 0, answer: isAnswer(path) }))
  392. .sort((a, b) => b.chars - a.chars);
  393. const answerChars = ranked.filter((r) => r.answer).reduce((s, r) => s + r.chars, 0);
  394. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  395. const out = [];
  396. out.push(`${indent}Explore envelope: ${envelope.toLocaleString('en-US')} chars over ${exploreTexts.length} response(s)`);
  397. if (answerGlobs.length) {
  398. out.push(`${indent} answer-set share: ${pct(envelope ? answerChars / envelope : 0)} | top file answers: ${ranked[0]?.answer ?? false}`);
  399. }
  400. for (const f of ranked.slice(0, 12)) {
  401. out.push(`${indent} ${f.answer ? '*' : ' '} ${pct(f.share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path}`);
  402. }
  403. if (ranked.length > 12) out.push(`${indent} … ${ranked.length - 12} more files`);
  404. return out.join('\n');
  405. }
  406. // ---------------------------------------------------------------------------
  407. // Explore sufficiency (CG-8)
  408. // ---------------------------------------------------------------------------
  409. // The agent's NEXT action after a codegraph_explore is free ground truth about
  410. // whether that response was enough. The buckets are chosen so each one maps to
  411. // a distinct fix:
  412. //
  413. // another codegraph call insufficient — the response did not answer
  414. // Read of a file we RETURNED allocation bug — right file, wrong bytes
  415. // Read of a file we did NOT recall bug — the file never surfaced
  416. // Grep/Glob recall bug (weaker: the agent is still hunting)
  417. // anything else / no tool sufficient — the agent moved on
  418. //
  419. // Three rules keep this honest:
  420. // * Only a call issued in a LATER assistant message counts as a reaction. A
  421. // Read fired in the same message as the explore was issued before its
  422. // response existed, so it cannot be a verdict on it (those are counted
  423. // separately as `concurrent`).
  424. // * ToolSearch/TodoWrite are stepped over: loading a deferred tool schema or
  425. // ticking a checklist says nothing about the response.
  426. // * SUBAGENT CALLS ARE A SEPARATE THREAD. Claude Code interleaves a subagent's
  427. // tool calls into the same stream, tagged `parent_tool_use_id` — verified on
  428. // a real run where a delegated search's greps landed between the parent's
  429. // own calls. Reactions are matched within one thread, or the subagent's
  430. // first grep would be scored as the parent's verdict on an explore it never
  431. // saw.
  432. //
  433. // A delegation (`Agent`/`Task`) is judged by what the SUBAGENT did first, since
  434. // that thread is right there in the transcript. Scoring the delegation itself as
  435. // "moved on" would have called this run sufficient while the subagent was off
  436. // grepping for the file — the one direction of error a tuning metric must not
  437. // have. A delegation that never runs a tool stays "moved on".
  438. /** Tools that carry no signal about whether the previous response was enough. */
  439. const TRANSPARENT_TOOLS = new Set(['ToolSearch', 'TodoWrite']);
  440. /** Tools that hand the work to a subagent whose thread we then judge instead. */
  441. const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
  442. /** Buckets, worst → best. Labels double as the summary rows. */
  443. const SUFFICIENCY = [
  444. ['explore_again', 'explore again', 'insufficient: did not answer'],
  445. ['read_returned', 'Read a file we returned', 'allocation: right file, wrong bytes'],
  446. ['read_missed', 'Read a file we did not return', 'recall: file never surfaced'],
  447. ['search', 'Grep/Glob', 'recall (weak): still hunting for the file'],
  448. ['sufficient', 'moved on / answered', 'sufficient'],
  449. ];
  450. const SUFFICIENCY_KEYS = SUFFICIENCY.map(([k]) => k);
  451. // Shell equivalents of Read and of Grep. Both arms have Bash, and on small
  452. // repos an agent reaches for `sed -n 100,200p file` as readily as for Read —
  453. // counting only the Read tool would score those explores as sufficient.
  454. const BASH_READ_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:cat|bat|head|tail|less|more|nl|sed|awk)\s+([^\n|;&]*)/;
  455. const BASH_SEARCH_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd|ls|tree)\b/;
  456. /** What a Bash command is really doing, as far as retrieval is concerned. */
  457. function bashIntent(cmd) {
  458. const c = String(cmd || '');
  459. // A heredoc or a redirect is WRITING a file — `cat <<EOF > x` must not read
  460. // as a Read.
  461. if (!/<</.test(c) && !/>\s*\S/.test(c)) {
  462. const m = BASH_READ_RE.exec(c);
  463. if (m) {
  464. // Drop flags and numeric arguments (`sed -n '100,200p' lib/x.js`), then
  465. // take the last path-shaped token.
  466. const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith('-') && !/^['"]?\d/.test(a));
  467. const path = args.reverse().find((a) => /[/.]/.test(a));
  468. if (path) return { kind: 'read', path: path.replace(/^['"]|['"]$/g, '') };
  469. }
  470. }
  471. if (BASH_SEARCH_RE.test(c)) return { kind: 'search' };
  472. return null;
  473. }
  474. const normPath = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
  475. /** Same file, with either side repo-relative and the other absolute. */
  476. function samePath(a, b) {
  477. const x = normPath(a), y = normPath(b);
  478. if (!x || !y) return false;
  479. return x === y || x.endsWith('/' + y) || y.endsWith('/' + x);
  480. }
  481. /**
  482. * The files whose SOURCE an explore response returned — its per-file sections,
  483. * which start with the unique ``**` `` marker (FILE_SECTION_PREFIX in
  484. * src/mcp/tools.ts). formatEnvelope keys off the same marker; it needs the byte
  485. * offsets too, which is why it re-scans rather than calling this.
  486. */
  487. export function exploreReturnedFiles(text) {
  488. return [...String(text ?? '').matchAll(/^\*\*`([^`]+)`\*\*/gm)].map((m) => m[1]);
  489. }
  490. // Every path-shaped token anywhere in a response — flow steps, blast radius,
  491. // symbol lists. A file in here but NOT in the returned set was POINTED AT and
  492. // not delivered, which is a different (and more damning) miss than one the
  493. // response never mentioned at all.
  494. const PATH_TOKEN_RE = /(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g;
  495. /**
  496. * The reaction one action represents, given what the explore had returned.
  497. * `earlier` is what PREVIOUS explores in the same thread returned: a re-read of
  498. * a file we already shipped is an allocation miss wherever it was shipped, and
  499. * filing it as recall would point the fix at the wrong end of the pipeline.
  500. */
  501. function reactionOf(action, returned, mentioned, earlier = []) {
  502. const { name, input } = action;
  503. const readOf = (path, prefix) => {
  504. const base = normPath(path).split('/').pop() || String(path ?? '');
  505. if (returned.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base}` };
  506. if (earlier.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base} (returned by an earlier explore)` };
  507. const named = mentioned.some((m) => samePath(path, m));
  508. return { bucket: 'read_missed', next: `${prefix}Read ${base}${named ? ' (named, not returned)' : ''}`, named };
  509. };
  510. if (/codegraph/.test(name)) return { bucket: 'explore_again', next: name.replace(/^mcp__[^_]*__/, '') };
  511. if (name === 'Read' || name === 'NotebookRead') return readOf(input.file_path ?? input.notebook_path, '');
  512. if (name === 'Grep' || name === 'Glob') return { bucket: 'search', next: name };
  513. if (name === 'Bash') {
  514. const intent = bashIntent(input.command);
  515. if (intent?.kind === 'read') return readOf(intent.path, 'Bash ');
  516. if (intent?.kind === 'search') return { bucket: 'search', next: 'Bash search' };
  517. }
  518. return { bucket: 'sufficient', next: name };
  519. }
  520. /** Is this action one of the ways an agent gets file bytes into its head? */
  521. const isFileAccess = (a) =>
  522. a.name === 'Read' || a.name === 'NotebookRead' || a.name === 'Grep' || a.name === 'Glob'
  523. || (a.name === 'Bash' && bashIntent(a.input?.command) !== null);
  524. /**
  525. * Bucket every answered codegraph_explore call in a transcript by what the
  526. * agent did next. Takes the raw JSONL events so it serves both transcript
  527. * shapes: stream-json runs (parse-run.mjs) and interactive session logs
  528. * (parse-session.mjs) — both emit one assistant event per content block with
  529. * `message.id`, and tool results as `tool_result` blocks in user messages.
  530. */
  531. export function classifySufficiency(events) {
  532. // One action list PER THREAD: 'main', plus one per subagent (keyed by the
  533. // delegating tool_use id, which is what `parent_tool_use_id` carries).
  534. const threads = new Map();
  535. const nameById = new Map();
  536. const textById = new Map(); // explore tool_use_id -> response text
  537. for (const ev of events) {
  538. const content = ev?.message?.content;
  539. if (!Array.isArray(content)) continue;
  540. const thread = ev.parent_tool_use_id ?? 'main';
  541. if (ev.type === 'assistant') {
  542. if (!threads.has(thread)) threads.set(thread, []);
  543. const list = threads.get(thread);
  544. for (const b of content) {
  545. if (b.type !== 'tool_use') continue;
  546. nameById.set(b.id, b.name);
  547. // No message.id (never seen on a real log) degrades to "every call is
  548. // its own message", i.e. same-message calls read as reactions.
  549. list.push({ msgId: ev.message.id || `#${thread}-${list.length}`, id: b.id, name: b.name, input: b.input || {} });
  550. }
  551. } else if (ev.type === 'user') {
  552. for (const b of content) {
  553. if (b.type !== 'tool_result') continue;
  554. const name = nameById.get(b.tool_use_id) || '';
  555. if (/codegraph_explore/.test(name) && !b.is_error) textById.set(b.tool_use_id, textOf(b.content));
  556. }
  557. }
  558. }
  559. const calls = [];
  560. let errors = 0, concurrent = 0;
  561. // What a delegation really did: the subagent's first substantive call. A
  562. // nested delegation is skipped rather than followed, so a subagent that only
  563. // spawns another subagent leaves the call as "moved on".
  564. const throughDelegation = (action, returned, mentioned, earlier) => {
  565. const first = (threads.get(action.id) || []).find((x) => !TRANSPARENT_TOOLS.has(x.name) && !DELEGATION_TOOLS.has(x.name));
  566. if (!first) return { bucket: 'sufficient', next: action.name };
  567. const r = reactionOf(first, returned, mentioned, earlier);
  568. return { ...r, next: `${action.name} → ${r.next}` };
  569. };
  570. for (const [thread, actions] of threads) {
  571. const earlier = []; // files previous explores in THIS thread already shipped
  572. for (let i = 0; i < actions.length; i++) {
  573. const a = actions[i];
  574. if (!/codegraph_explore/.test(a.name)) continue;
  575. const text = textById.get(a.id);
  576. // No response text = the call errored, or the run ended before it
  577. // returned. Nothing to judge the sufficiency of; count it and move on.
  578. if (text === undefined) { errors++; continue; }
  579. const returned = exploreReturnedFiles(text);
  580. const mentioned = text.match(PATH_TOKEN_RE) || [];
  581. let reaction = { bucket: 'sufficient', next: '(final answer)' };
  582. for (let j = i + 1; j < actions.length; j++) {
  583. const b = actions[j];
  584. if (b.msgId === a.msgId) { if (isFileAccess(b)) concurrent++; continue; }
  585. if (TRANSPARENT_TOOLS.has(b.name)) continue;
  586. reaction = DELEGATION_TOOLS.has(b.name)
  587. ? throughDelegation(b, returned, mentioned, earlier)
  588. : reactionOf(b, returned, mentioned, earlier);
  589. break;
  590. }
  591. earlier.push(...returned);
  592. calls.push({ thread, query: String(a.input.query ?? ''), files: returned.length, chars: text.length, ...reaction });
  593. }
  594. }
  595. const counts = Object.fromEntries(SUFFICIENCY_KEYS.map((k) => [k, 0]));
  596. for (const c of calls) counts[c.bucket]++;
  597. return { calls, counts, errors, concurrent, answered: calls.length };
  598. }
  599. /** The sufficiency block, as printed under a run and reused by aggregators. */
  600. export function formatSufficiency(s, indent = ' ') {
  601. const f = s.sufficiency;
  602. if (!f.answered) {
  603. return `${indent}Explore sufficiency: no answered codegraph_explore calls`
  604. + (f.errors ? ` (${f.errors} errored or never returned)` : '');
  605. }
  606. const pct = (n) => ((n / f.answered) * 100).toFixed(0) + '%';
  607. const out = [`${indent}Explore sufficiency — what the agent did NEXT (${f.answered} answered call${f.answered === 1 ? '' : 's'}):`];
  608. for (const [key, label, meaning] of SUFFICIENCY) {
  609. out.push(`${indent} ${String(f.counts[key]).padStart(3)} ${pct(f.counts[key]).padStart(4)} ${label.padEnd(31)}${meaning}`);
  610. }
  611. f.calls.forEach((c, i) => {
  612. const q = c.query.length > 46 ? c.query.slice(0, 45) + '…' : c.query;
  613. const where = c.thread && c.thread !== 'main' ? ' [subagent]' : '';
  614. out.push(`${indent} ${i + 1}.${where} "${q}" [${c.files} file${c.files === 1 ? '' : 's'}] → ${c.next}`);
  615. });
  616. const notes = [];
  617. if (f.errors) notes.push(`${f.errors} errored/unanswered call${f.errors === 1 ? '' : 's'} (not bucketed)`);
  618. if (f.concurrent) notes.push(`${f.concurrent} file-access call${f.concurrent === 1 ? '' : 's'} in the SAME message as an explore (not a reaction)`);
  619. if (notes.length) out.push(`${indent} note: ${notes.join(' · ')}`);
  620. return out.join('\n');
  621. }
  622. // ---------------------------------------------------------------------------
  623. // `--selftest`: the occupancy math over synthetic transcripts with known
  624. // answers. It lives here rather than in a test file on purpose — a new
  625. // scripts/agent-eval/*.mjs scores into the self-query eval fixture's corpus.
  626. function selftest() {
  627. const { writeFileSync, mkdtempSync } = require0('fs');
  628. const { join } = require0('path');
  629. const { tmpdir } = require0('os');
  630. const dir = mkdtempSync(join(tmpdir(), 'cg-occ-'));
  631. let n = 0, failures = 0;
  632. const check = (name, got, want, tol) => {
  633. n++;
  634. const ok = Math.abs(got - want) <= tol;
  635. if (!ok) failures++;
  636. console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${Math.round(got)}, want ${want} ±${tol}`);
  637. };
  638. // Builders for the event shapes Claude Code actually emits.
  639. const req = (ctx, id, blocks) => blocks.map((b) => JSON.stringify({
  640. type: 'assistant',
  641. message: { id, content: [b], usage: { input_tokens: ctx, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 2 } },
  642. }));
  643. const use = (id, name, input = {}) => ({ type: 'tool_use', id, name, input });
  644. const res = (id, chars) => JSON.stringify({
  645. type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: 'x'.repeat(chars) }] }] },
  646. });
  647. const done = () => JSON.stringify({ type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {} });
  648. const write = (name, lines) => { const f = join(dir, name); writeFileSync(f, lines.join('\n') + '\n'); return f; };
  649. // 1. Attribution: ratio 2.5 chars/tok, two families, no shedding.
  650. // 10,000 explore chars over a 4,000-tok gap; 5,000 Read chars over 2,000.
  651. let f = write('basic.jsonl', [
  652. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  653. res('t1', 10000),
  654. ...req(14000, 'm2', [use('t2', 'Read')]),
  655. res('t2', 5000),
  656. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  657. done(),
  658. ]);
  659. let o = parseSession([f]).occupancy;
  660. check('chars/token', o.charsPerToken * 1000, 2500, 30);
  661. check('codegraph residual', o.residual.codegraph, 4000, 60);
  662. check('Read residual', o.residual.read, 2000, 40);
  663. check('file-access residual', o.residualFileAccess, 2000, 40);
  664. check('final context', o.ctxFinal, 16000, 0);
  665. check('fixed base', o.ctxBase, 10000, 0);
  666. check('nothing evicted', o.evicted, 0, 1);
  667. // 2. Dedupe: thinking + tool_use are two events sharing one id and one usage.
  668. // Counting usage per event would report 5 requests instead of 3.
  669. f = write('dupe.jsonl', [
  670. ...req(10000, 'm1', [{ type: 'thinking', thinking: '' }, use('t1', 'mcp__codegraph__codegraph_explore')]),
  671. res('t1', 10000),
  672. ...req(14000, 'm2', [{ type: 'thinking', thinking: '' }, use('t2', 'Read')]),
  673. res('t2', 5000),
  674. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  675. done(),
  676. ]);
  677. let s = parseSession([f]);
  678. check('turns deduped by message.id', s.turns, 3, 0);
  679. check('codegraph residual (deduped)', s.occupancy.residual.codegraph, 4000, 60);
  680. // 3. Compaction: the boundary clears everything resident before it.
  681. f = write('compact.jsonl', [
  682. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  683. res('t1', 10000),
  684. ...req(14000, 'm2', [use('t2', 'mcp__codegraph__codegraph_explore')]),
  685. JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
  686. res('t2', 5000),
  687. ...req(8000, 'm3', [{ type: 'text', text: 'done' }]),
  688. done(),
  689. ]);
  690. o = parseSession([f]).occupancy;
  691. check('post-compaction residual = last result only', o.residual.codegraph, 2000, 40);
  692. check('contributed still counts both', o.contributed.codegraph, 6000, 80);
  693. // 4. Micro-compaction: context grows less than the results added, so the
  694. // oldest result is shed first (FIFO) — here explore, leaving Read.
  695. f = write('micro.jsonl', [
  696. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  697. res('t1', 10000),
  698. ...req(14000, 'm2', [use('t2', 'Read')]),
  699. res('t2', 10000),
  700. ...req(14500, 'm3', [{ type: 'text', text: 'done' }]), // +500 for 4,000 tok of Read
  701. done(),
  702. ]);
  703. o = parseSession([f]).occupancy;
  704. check('FIFO evicted the older codegraph result', o.residual.codegraph, 500, 60);
  705. check('newer Read result survives', o.residual.read, 4000, 60);
  706. check('eviction recorded', o.evicted, 3500, 60);
  707. // 5. Multi-turn stitching: a resumed segment continues the same context, and
  708. // a turn that calls no tool leaves the earlier residual in place.
  709. const a = write('seg1.jsonl', [
  710. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  711. res('t1', 10000),
  712. ...req(14000, 'm2', [{ type: 'text', text: 'answer one' }]),
  713. done(),
  714. ]);
  715. const b = write('seg2.jsonl', [
  716. ...req(14600, 'm3', [{ type: 'text', text: 'answer two, from what is already here' }]),
  717. done(),
  718. ]);
  719. s = parseSession([a, b]);
  720. check('stitched turns', s.turns, 3, 0);
  721. check('residual carries into turn 2', s.occupancy.residual.codegraph, 4000, 60);
  722. check('stitched final context', s.occupancy.ctxFinal, 14600, 0);
  723. check('stitched cost sums segments', s.cost * 100, 20, 0.1);
  724. // ---- 6. Explore sufficiency: the bucket each explore call earns. --------
  725. const checkIs = (name, got, want) => {
  726. n++;
  727. const ok = got === want;
  728. if (!ok) failures++;
  729. console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
  730. };
  731. const EXPLORE = 'mcp__codegraph__codegraph_explore';
  732. // An explore response's shape that matters here: one `**`path`**` section per
  733. // file whose source it returned, plus whatever else it named.
  734. const exploreRes = (id, paths, extra = '', isError = false) => JSON.stringify({
  735. type: 'user',
  736. message: {
  737. content: [{
  738. type: 'tool_result', tool_use_id: id, ...(isError ? { is_error: true } : {}),
  739. content: [{ type: 'text', text: paths.map((p) => `**\`${p}\`** — fn(function)\n\n1\tcode here\n`).join('\n') + extra }],
  740. }],
  741. },
  742. });
  743. const suff = (lines) => classifySufficiency(lines.map((l) => JSON.parse(l)));
  744. // explore → explore is insufficient; the second explore → a Read of a file it
  745. // RETURNED is the allocation bucket (this is the CG-22 express baseline).
  746. let sf = suff([
  747. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'res.send Content-Type ETag generation' })]),
  748. exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
  749. ...req(12000, 'm2', [use('e2', EXPLORE, { query: 'response.js res.send function body' })]),
  750. exploreRes('e2', ['lib/response.js']),
  751. ...req(14000, 'm3', [use('r1', 'Read', { file_path: '/private/tmp/t-base/lib/response.js' })]),
  752. res('r1', 3722),
  753. ...req(15000, 'm4', [{ type: 'text', text: 'done' }]),
  754. done(),
  755. ]);
  756. check('two answered explore calls', sf.answered, 2, 0);
  757. checkIs('explore → explore = insufficient', sf.calls[0].bucket, 'explore_again');
  758. checkIs('explore → Read of a returned file (abs path)', sf.calls[1].bucket, 'read_returned');
  759. // A file the response NAMED but did not return is still a recall miss — and
  760. // is flagged as named, since pointing without delivering is its own failure.
  761. sf = suff([
  762. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  763. exploreRes('e1', ['lib/response.js'], '\n**Flow**\n1. lib/router/index.js:42 handle\n'),
  764. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/router/index.js' })]),
  765. res('r1', 100),
  766. done(),
  767. ]);
  768. checkIs('explore → Read of a named-but-unreturned file', sf.calls[0].bucket, 'read_missed');
  769. checkIs(' …flagged as named', sf.calls[0].named, true);
  770. sf = suff([
  771. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  772. exploreRes('e1', ['lib/response.js']),
  773. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/never/mentioned.js' })]),
  774. res('r1', 100),
  775. done(),
  776. ]);
  777. checkIs('explore → Read of a file never surfaced', sf.calls[0].bucket, 'read_missed');
  778. checkIs(' …not flagged as named', sf.calls[0].named, false);
  779. // Grep, and the shell equivalents of Read and Grep.
  780. const oneShot = (next) => suff([
  781. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  782. exploreRes('e1', ['lib/response.js']),
  783. ...req(12000, 'm2', [next]),
  784. res(next.id, 100),
  785. done(),
  786. ]).calls[0];
  787. checkIs('explore → Grep', oneShot(use('g1', 'Grep', { pattern: 'send' })).bucket, 'search');
  788. checkIs('explore → Glob', oneShot(use('g1', 'Glob', { pattern: '**/*.js' })).bucket, 'search');
  789. checkIs('explore → Bash sed of a returned file',
  790. oneShot(use('b1', 'Bash', { command: "sed -n '100,200p' lib/response.js" })).bucket, 'read_returned');
  791. checkIs('explore → Bash grep', oneShot(use('b1', 'Bash', { command: 'grep -rn send lib/' })).bucket, 'search');
  792. checkIs('explore → Bash that writes a file is not a read',
  793. oneShot(use('b1', 'Bash', { command: "cat > /tmp/note.md <<'EOF'\nx\nEOF" })).bucket, 'sufficient');
  794. checkIs('explore → Bash npm test = moved on',
  795. oneShot(use('b1', 'Bash', { command: 'npm test' })).bucket, 'sufficient');
  796. checkIs('explore → Edit = sufficient',
  797. oneShot(use('x1', 'Edit', { file_path: '/t/lib/response.js' })).bucket, 'sufficient');
  798. // No further tool call at all: the agent answered from the response.
  799. sf = suff([
  800. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  801. exploreRes('e1', ['lib/response.js']),
  802. ...req(12000, 'm2', [{ type: 'text', text: 'here is how it works' }]),
  803. done(),
  804. ]);
  805. checkIs('explore → final answer', sf.calls[0].bucket, 'sufficient');
  806. checkIs(' …labelled as the final answer', sf.calls[0].next, '(final answer)');
  807. // A Read issued in the SAME message as the explore predates its response, so
  808. // it is not a verdict on it — step past it and count it separately.
  809. sf = suff([
  810. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' }), use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  811. exploreRes('e1', ['lib/response.js']),
  812. res('r1', 100),
  813. ...req(12000, 'm2', [use('x1', 'Edit', { file_path: '/t/lib/response.js' })]),
  814. res('x1', 20),
  815. done(),
  816. ]);
  817. checkIs('same-message Read is not a reaction', sf.calls[0].bucket, 'sufficient');
  818. check(' …counted as concurrent instead', sf.concurrent, 1, 0);
  819. // ToolSearch/TodoWrite carry no signal — the Read behind them is the verdict.
  820. sf = suff([
  821. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  822. exploreRes('e1', ['lib/response.js']),
  823. ...req(12000, 'm2', [use('t1', 'TodoWrite', {})]),
  824. res('t1', 20),
  825. ...req(13000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  826. res('r1', 100),
  827. done(),
  828. ]);
  829. checkIs('bookkeeping tools are stepped over', sf.calls[0].bucket, 'read_returned');
  830. // A re-read of a file an EARLIER explore shipped is still an allocation miss:
  831. // we returned it and clipped it wrong, just not on this call.
  832. sf = suff([
  833. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'first' })]),
  834. exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
  835. ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'second' })]),
  836. exploreRes('e2', ['lib/response.js']),
  837. ...req(12000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/utils.js' })]),
  838. res('r1', 400),
  839. done(),
  840. ]);
  841. checkIs('re-read of an earlier explore’s file is allocation, not recall',
  842. sf.calls[1].bucket, 'read_returned');
  843. checkIs(' …and says which explore returned it',
  844. sf.calls[1].next, 'Read utils.js (returned by an earlier explore)');
  845. // A subagent's calls are interleaved into the same stream under
  846. // `parent_tool_use_id` (verified on a real excalidraw run). They belong to
  847. // their own thread: the parent's verdict is the delegation, judged by what
  848. // the subagent actually did first — here, grepping for a file we never
  849. // returned.
  850. const sub = (parent, obj) => JSON.stringify({ ...JSON.parse(obj), parent_tool_use_id: parent });
  851. sf = suff([
  852. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  853. exploreRes('e1', ['lib/response.js']),
  854. ...req(12000, 'm2', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
  855. ...req(0, 'sm1', [use('sb1', 'Bash', { command: 'grep -rn nonce lib/' })]).map((l) => sub('a1', l)),
  856. sub('a1', res('sb1', 400)),
  857. ...req(14000, 'm3', [{ type: 'text', text: 'done' }]),
  858. res('a1', 900),
  859. done(),
  860. ]);
  861. checkIs('delegation is judged by what the subagent did', sf.calls[0].bucket, 'search');
  862. checkIs(' …and says so', sf.calls[0].next, 'Agent → Bash search');
  863. // A subagent's Read must NOT be read as the parent's reaction to an explore
  864. // the subagent never saw: the parent moved on, the subagent's own explore is
  865. // judged inside its own thread.
  866. sf = suff([
  867. ...req(10000, 'm1', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
  868. ...req(0, 'sm1', [use('e1', EXPLORE, { query: 'sub q' })]).map((l) => sub('a1', l)),
  869. sub('a1', exploreRes('e1', ['lib/response.js'])),
  870. ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'parent q' })]),
  871. exploreRes('e2', ['lib/other.js']),
  872. ...req(0, 'sm2', [use('sr1', 'Read', { file_path: '/t/lib/response.js' })]).map((l) => sub('a1', l)),
  873. sub('a1', res('sr1', 400)),
  874. ...req(13000, 'm3', [{ type: 'text', text: 'done' }]),
  875. done(),
  876. ]);
  877. check('both threads bucketed', sf.answered, 2, 0);
  878. checkIs('parent explore is not blamed for a subagent Read',
  879. sf.calls.find((c) => c.query === 'parent q').bucket, 'sufficient');
  880. checkIs('subagent explore is judged in its own thread',
  881. sf.calls.find((c) => c.query === 'sub q').bucket, 'read_returned');
  882. // An errored explore has no response to judge; it is counted, not bucketed.
  883. sf = suff([
  884. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  885. exploreRes('e1', [], 'not indexed', true),
  886. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  887. res('r1', 100),
  888. done(),
  889. ]);
  890. check('errored explore is not bucketed', sf.answered, 0, 0);
  891. check(' …but is counted', sf.errors, 1, 0);
  892. console.log(`\n${n - failures}/${n} checks passed`);
  893. return failures;
  894. }
  895. // `--selftest` needs sync fs helpers the module path doesn't import at top level.
  896. function require0(m) { return process.getBuiltinModule(m); }
  897. const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
  898. if (isMain && process.argv.includes('--selftest')) process.exit(selftest() ? 1 : 0);
  899. if (isMain) {
  900. // `--answer <glob>` is repeatable and implies `--envelope`. Its VALUE is not a
  901. // run file, so consume it here rather than letting the positional filter below
  902. // mistake a glob for a log path.
  903. const argv = process.argv.slice(2);
  904. const files = [];
  905. const answerGlobs = [];
  906. let wantEnvelope = false;
  907. for (let i = 0; i < argv.length; i++) {
  908. if (argv[i] === '--envelope') wantEnvelope = true;
  909. else if (argv[i] === '--answer') { answerGlobs.push(argv[++i]); wantEnvelope = true; }
  910. else if (!argv[i].startsWith('--')) files.push(argv[i]);
  911. }
  912. if (!files.length) { console.error('usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--envelope] [--answer <glob>]... | --selftest'); process.exit(1); }
  913. const s = parseSession(files);
  914. console.log(`\n=== ${files.map((f) => f.split('/').pop()).join(' + ')} ===`);
  915. console.log(`codegraph tools exposed: ${s.initTools ? s.initTools.length : '?'}${s.raced ? ' [MCP COLD-START RACE — tool call hit "No such tool available"]' : ''}`);
  916. if (s.cliContaminated) console.log(`!! ${s.cliContaminated} codegraph CLI call${s.cliContaminated === 1 ? '' : 's'} RETURNED OUTPUT via Bash — if this is a without-arm, the run is CONTAMINATED`);
  917. else if (s.cliCalls) console.log(` (${s.cliCalls} codegraph CLI attempt${s.cliCalls === 1 ? '' : 's'} blocked — no output entered the window)`);
  918. console.log(`\nTool calls (${s.toolCalls.length}):`);
  919. console.log(' by type:', JSON.stringify(s.counts));
  920. s.toolCalls.forEach((tc, i) => console.log(` ${i + 1}. ${tc}`));
  921. if (s.result) {
  922. const seg = s.results.length > 1 ? ` | ${s.results.length} segments (${s.results.map((r) => r.subtype).join(',')})` : '';
  923. console.log(`\nResult: ${s.result.subtype} | duration ${s.dur.toFixed(0)}s | turns ${s.turns}${seg}`);
  924. console.log(` tokens processed: ${s.processed.toLocaleString('en-US')} | cost $${s.cost.toFixed(3)}`);
  925. }
  926. console.log('');
  927. console.log(formatOccupancy(s));
  928. console.log('');
  929. console.log(formatSufficiency(s));
  930. if (wantEnvelope) {
  931. console.log('');
  932. console.log(formatEnvelope(s.exploreTexts, answerGlobs));
  933. }
  934. }