test(agent-eval): bucket every explore by what the agent did next (CG-8)

The agent's next action after a codegraph_explore is free ground truth about
whether the response was enough, and the harness was discarding it. Every run
now bucketed: explored again (insufficient), Read a file we returned
(allocation -- right file, wrong bytes), Read a file we did not return (recall),
Grep/Glob (recall, weak), or moved on (sufficient). The buckets are chosen so
each one names a distinct fix.

The classifier lives in parse-run.mjs next to the occupancy math and takes raw
events, so parse-session.mjs reuses it for interactive runs -- no new
scripts/agent-eval/*.mjs, which would score into the self-query eval fixture's
corpus.

Four rules, three of them found by validating against real transcripts rather
than reasoned up front:

  * A call in the SAME assistant message as the explore predates its response,
    so it is not a verdict on it. Stepped over, counted as `concurrent`.
  * ToolSearch/TodoWrite carry no signal; the call behind them is the verdict.
  * SUBAGENTS ARE A SEPARATE THREAD. Claude Code interleaves a subagent's calls
    into the same stream under parent_tool_use_id -- verified on a live
    excalidraw run where a delegated search's greps landed between the parent's
    own calls. Matching reactions across threads scored the subagent's grep as
    the parent's verdict on an explore it never saw. A delegation is judged by
    what the subagent did FIRST: before that, the same run reported 33%
    sufficient while the subagent was off grepping for the file, which is the
    one direction of error a tuning metric must not have. In interactive
    sessions the subagent is a separate FILE instead, so parse-session.mjs
    stitches the threads back with the toolUseId in agent-*.meta.json.
  * A re-read of a file an EARLIER explore shipped is still allocation, not
    recall -- filing it as recall aims the fix at the wrong end of the pipeline.

Shell file access counts too (`sed -n 100,200p f` reads, `grep`/`find` search),
since both arms have Bash and counting only the Read tool would score those
explores as sufficient. A heredoc or redirect is writing, not reading.

Validated by hand on cg22/ab-express/run-baseline-1 (explore, explore, Read of
lib/response.js which explore #2 returned -- the #1500 allocation bug as a
bucket instead of a hunch; the new-build arm is 100% sufficient) and on
cg15/ab-express/run-new-2 (four explores, the last returning lib/utils.js which
the agent then read at offset 195). Swept over all 76 A/B logs on this machine:
0 crashes, 176 calls bucketed. --selftest covers every bucket, both thread
rules, delegation, shell reads/searches and errored calls: 46/46.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-05 00:29:50 -05:00
co-authored by Claude Opus 5
parent 52b194a6be
commit a2a916e1a5
2 changed files with 445 additions and 1 deletions
+27
View File
@@ -6,6 +6,7 @@
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { classifySufficiency, formatSufficiency } from './parse-run.mjs';
const projectArg = process.argv[2];
if (!projectArg) { console.error('usage: parse-session.mjs <project-dir>'); process.exit(1); }
@@ -91,3 +92,29 @@ if (existsSync(subDir)) {
}
const k = (n) => (n / 1000).toFixed(1) + 'k';
console.log(`TOKENS: gen ${k(tok.gen)} | fresh-in ${k(tok.fresh)} | cached-in ${k(tok.cached)} | billable≈ ${k(tok.gen + tok.fresh)}`);
// What the agent did after each codegraph_explore (CG-8) — the same classifier
// the headless A/B uses, over the interactive transcript.
//
// A subagent's calls live in their OWN file here (headless stream-json
// interleaves them into one stream instead), so they are stitched back in:
// each `agent-*.meta.json` carries the `toolUseId` of the Task that spawned it,
// which is exactly the `parent_tool_use_id` the classifier keys threads on.
// Without that, a delegated search would score as "the agent moved on".
const parseLines = (file, parentToolUseId) => readFileSync(file, 'utf8').split('\n')
.filter(Boolean)
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean)
.map((ev) => (parentToolUseId ? { ...ev, parent_tool_use_id: parentToolUseId } : ev));
const events = parseLines(join(projDir, sessionId + '.jsonl'));
if (existsSync(subDir)) {
for (const f of readdirSync(subDir).filter((f) => f.endsWith('.jsonl'))) {
let parent = null;
const meta = join(subDir, f.replace(/\.jsonl$/, '.meta.json'));
if (existsSync(meta)) { try { parent = JSON.parse(readFileSync(meta, 'utf8')).toolUseId ?? null; } catch { /* unreadable */ } }
events.push(...parseLines(join(subDir, f), parent ?? `subagent:${f}`));
}
}
console.log('');
console.log(formatSufficiency({ sufficiency: classifySufficiency(events) }, ''));