Skip to content

cc-explorer: make failure a queryable axis (errors_only + survey_failures) - #64

Open
coryking wants to merge 4 commits into
mainfrom
failures-axis
Open

cc-explorer: make failure a queryable axis (errors_only + survey_failures)#64
coryking wants to merge 4 commits into
mainfrom
failures-axis

Conversation

@coryking

Copy link
Copy Markdown
Owner

Closes #57.

Failure was not a queryable axis of the transcript corpus. You could slice by project,
session, agent, role, pattern, and date — but not by "this call failed," even though
11,583 tool results carry is_error: true. The only path to them was guessing the error's
prose and regexing for it.

Two surveyed episodes measured that tax: one agent fired ~30 guessed vocabulary
patterns
to approximate "show me failed connections near host X," and another required
its orchestrator to pre-write ~25 literal candidate error strings into the dispatch
prompt
before it could start.

What this adds

errors_only on search_projects / grep_session / grep_sessions — narrows hits to
tool results that actually failed. On the issue's own scenario, patterns=["ssh"] over
home-it-services goes from 6,193 hits to 74 actual failures, surfacing
Connection refused / No route to host / Could not resolve hostname with nothing
guessed.

survey_failures — a cross-project rollup. audit_session_tools' error machinery
promoted out of single-session, single-agent scope, plus a 16-kind taxonomy over a
5-value category axis (agent / policy / environment / cascade / unknown). Cascade
errors are suppressed from headline counts by default; unclassified sits third in the
payload because that bucket is where novel breakage lives.

FailureKind / FailureCategory in models.py, sited as a deliberate parallel to the
existing UserOrigin enum, with ToolResultContent.failure as the single classification
entry point. collect_tool_calls — the tool_use↔tool_result pairing walk — was lifted out
of subagents.py so the single-session audit and the corpus survey share one
implementation.

Payload economy

Default output is counts only — roughly 1,180 tokens for a 7-day window. Error text is
opt-in via examples=true. All four list sections are capped by limit (default 10) with
an explicit *_overflow line naming what was dropped; nothing truncates silently. No
existing response model gained a field.

Design decision worth reading

The issue prescribed reusing _result_is_error as the error gate. That was overridden with
measurement: of tool results carrying "not found"/"no matches" in their first 200 chars,
1,095 are not flagged as errors versus 567 that are. Letting prose promote successes to
failures would bury real breakage in the one tool whose entire value is precision. So
is_error is the gate and prose only classifies; the old heuristic survives under its true
name, looks_like_no_match, used only by audit_session_tools, whose question ("did this
come back empty?") is genuinely different.

Review

Three reviewers (correctness, Python, project-standards). Seven correctness fixes, five
design cleanups, and eight new tests targeting the gaps that let the bugs through. The
material ones, all re-verified corpus-wide after fixing:

  • ENOTFOUND matched ModuleNotFoundError — no word boundary, re.I. The network
    bucket was 175 entries, 96 of them Python tracebacks. That poisoned the environment
    category the docs point surveyors at. After anchoring: network 175 → 76, Python
    tracebacks 96 → 0.
  • collect_tool_calls assumed file order is causal order — JSONL flushes out of order,
    so a tool_result written before its tool_use was silently dropped. 740 of 257,986
    blocks corpus-wide; failures visible to the survey went 11,532 → 11,576. This also had
    the two halves of the feature disagreeing: errors_only found these, the survey did not.
    An equivalence-oracle test now pins them together.
  • kinds='cascade' was unreachable — suppression ran before the kinds filter, so it
    always returned zero and the error told you to widen the window. Naming cascade now
    implies include_cascade.
  • bad_path's bare does not exist filed 244 Postgres schema errors under category
    agent — the category the triage workflow tells you to filter out.
  • errors_only drill-in showed the wrong context — at the default role=user the
    causing assistant turn (median 1 entry back) was structurally never shown, while the
    nearest human turn was a median of 8 away, p90 81, and 24 failed results had no preceding
    human turn at all.
  • Whole-text normalization to read a 1,000-char window — 96× slower on a 540 KB result,
    in a package with a 5.5 GB RSS incident in its history.
  • short_name collapsed distinct MCP tools — 17 of 153 short names are shared by 2–3
    servers, blending their error rates in a denominator the issue calls load-bearing.

Tests: 480 → 507 passed, 1 xfailed. No existing test weakened.

Known scope limit

Filed #62 separately: Corpus.discover enumerates enc.glob("*.jsonl") — top-level main
transcripts only — so 430 session directories whose parent transcript is gone strand
2,373 subagent files
(~32% of transcripts) outside every search path. Pre-existing, not
caused by this work, but it bounds this tool the same way it already silently bounds
search_projects and grep_session.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kor6NdZegwKxn55hdvZiot

coryking and others added 4 commits July 30, 2026 11:01
Failure was reachable only by guessing what an error said and regexing for
the prose, even though every tool result carries a structured `is_error`
flag. This lays the foundation for querying it directly.

models.py — the failure taxonomy, sited next to UserOrigin as its exact
parallel: the ONE place that answers "did this tool call fail, and how?"
FailureKind classifies, FailureCategory says whose problem it is (agent /
policy / environment / cascade), and `ToolResultContent.failure` is the only
entry point. `is_error` is the GATE; prose only classifies. Measured on the
live corpus, a marker heuristic promotes ~1400 SUCCESSFUL results to
failures — npm logs and ssh banners that merely contain "not found" — which
would bury real breakage, so the old `_result_is_error` heuristic is kept
under its true name (`looks_like_no_match`) as the narrower zero-hit
question audit_session_tools actually asks.

`collect_tool_calls` lifts the tool_use -> tool_result pairing out of
subagents.py so the single-session audit and the corpus-wide survey share
one walk. `extract_agent_tool_audit` is rebuilt on it, with its broader
"error" definition (failure OR zero-hit) preserved and now documented.
`ToolResultContent.text` replaces the third copy of the content-flattening
logic.

corpus.py — `matching_files` exposes the rg prefilter at FILE granularity
and without the `rg_safe` gate, which exists to protect USER regexes written
for extracted text from JSON string escaping; a pattern authored against the
raw encoding has no such gap. File granularity is what stops one failing
subagent body from dragging its session's other twenty transcripts through
the parser. `candidate_refs` is now expressed on top of it.

search.py — `errors_only` restricts matches to failed tool results, so the
pattern narrows the TOPIC rather than the error vocabulary. `session_sources`
takes a Path instead of a SessionInfo: the expansion is a filesystem walk and
must be reachable from a SessionRef without paying for a parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kor6NdZegwKxn55hdvZiot
…eryable axis (#57)

Two surfaces over the classification foundation.

`errors_only` on search_projects / grep_session / grep_sessions restricts
hits to failed tool calls. On the real corpus, patterns=["ssh"] over
home-it-services drops 6193 hits to the 74 that actually failed — which is
the whole point: two surveyed episodes had agents fire ~30 guessed
vocabulary patterns and pre-write ~25 literal candidate error strings into a
dispatch prompt because no primitive existed for "give me every failed call."
search_projects narrows the corpus by the raw `is_error` literal before the
pattern prefilter runs. errors_only with hide='outputs' is refused rather
than confidently returning nothing.

`survey_failures` is the landing page: every failure in a window, classified
and rolled up by kind, tool and session. Cost scales with the answer — rg
names the ~43% of files that can contribute before anything is parsed, and
`after` additionally prunes by mtime. A full unwindowed pass over 4.6 GB
runs in ~33s at 463 MB peak RSS.

Three things the payload gets right:

- Cascade suppression is on by default. One failure in a parallel batch
  cancels its siblings and each cancellation is recorded as its own error;
  counting them multiplies that batch. 444 suppressed corpus-wide, reported
  as `cascade_suppressed` and recoverable with include_cascade.
- `unclassified` is not a junk drawer at the bottom. It sits directly after
  by_kind, grouped by leading text and ranked by recurrence, because the
  taxonomy exists to subtract KNOWN noise so novel breakage stands out.
- Nothing is unconditional. The default response is counts only (~1.2k tokens
  for a 7-day window); error text is opt-in via `examples`, every list is
  capped by `limit`, and any capped list carries an explicit overflow line
  naming what was dropped — a silently truncated list reads as "that's all of
  them" and ends the investigation. No existing response model gained a field.

Verified against ~/.claude/projects with an independent raw-JSONL oracle:
8369 of 8535 in-corpus flagged errors captured (98.1%), per-kind counts
matching exactly for 6 of 16 kinds and within 4% for the rest. The residual
is genuine — 52 tool_results whose tool_use is absent from the file, plus
conversion-artifact copies that are deliberately skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kor6NdZegwKxn55hdvZiot
Applies the consolidated review of the failures-axis diff. Every corpus
number below was re-measured against ~/.claude/projects (7,494 transcripts,
11,583 flagged errors) before and after.

Correctness

- Anchor the bare uppercase error codes in the rule table. `ENOTFOUND` under
  `re.I` with no word boundary substring-matches `ModuleNotFoundError` and
  `FileNotFoundError`, and `network` runs before `bad_path`, so a real
  FileNotFoundError could never reach its own kind. The `network` bucket goes
  175 -> 76, of which Python tracebacks go 96 -> 0. `ECONNREFUSED`, `ENOENT`,
  `EISDIR`, `ETIMEDOUT` anchored the same way.
- Require a filesystem noun in front of `bad_path`'s `does not exist`. Bare,
  it is also every SQL error's phrasing: 244 Postgres `column "x" does not
  exist` results were filed as category `agent`, the category the docs tell
  surveyors to filter OUT. bad_path 1592 -> 1364; the moved rows land in
  exit_code (environment). Real paths, including long quoted ones, still
  classify.
- Pair tool calls in two passes. `tool_use_id` is the causal link; file order
  is not. 740 of 258k tool_result blocks are written before the tool_use they
  answer, and the single forward pass dropped every one — the failure vanished
  from survey_failures while errors_only still found it. Recovers 44 failures
  across 39 transcripts (11,532 -> 11,576).
- Make `kinds=['cascade']` answerable. Suppression ran first, so it always
  returned total=0 and the MCP layer then offered three remedies, none of them
  the real one. Naming cascade in `kinds` now implies include_cascade, and the
  zero-result error names include_cascade whenever cascades were suppressed.
- Bound the text before normalizing it. `" ".join(text.split())[:LIMIT]`
  normalized whole 540 KB results to read a 1000-char window; `collapse_ws`
  slices first. FailureExample construction is deferred to the two places that
  can keep one (first of a kind, first of a shape) instead of being built for
  every failure and discarded.
- Include the assistant side in `errors_only` context. The match set narrows to
  ToolResultEntry but context was still composed from `role`, so at the default
  role='user' the tool_use that caused the failure was structurally unreachable.
  Measured: the causing assistant turn is a median of 1 entry back; the nearest
  human turn is a median of 8 (p90 81, max 677) and for 24 failures does not
  exist at all.
- Anchor the cascade rule to the head of the window. All 1246 in the corpus
  start there, so this is free — and it stops a result that merely QUOTES
  "Sibling tool call errored" from being suppressed, which in a repo that greps
  transcripts for a living is a real path.

Design

- Add `has_failure` (the flag, no regex) beside `failure` (the classifier).
  errors_only asked `failure is None` once per pattern per entry and threw the
  kind away; the survey and the audit walk did the same.
- Move NO_MATCH_MARKERS / looks_like_no_match back to subagents.py, its one
  caller. models.py keeps the explanatory note pointing at it.
- Justify session_sources' Path signature by using it: failures.py maps its
  rg-matched paths through it instead of re-deriving agent ids from filenames
  and re-reading provenance. Two walks answering the same question, now one.
- One error-prefilter degrade path (`failures.error_candidate_files`) instead
  of the same try/except/warn/fall-back in two modules.
- parse_kinds joins parse_hide in models.py; the MCP boundary keeps only the
  ValueError -> ToolError wrapper.

Judgment calls

- `by_tool` keys on the FULL tool name and resolves a display name that
  collapses to the short form only when unambiguous. 17 short names in the
  corpus are shared by two or three MCP servers; merging them blends the
  error_rate denominator that is the whole point of the section.
- parse_error is category `agent`, not `environment`. All 163 sampled: python
  -c heredocs with broken f-string quoting, malformed jq filters, injected JS
  with `await` outside async. That is the agent getting its own code wrong.
- FailureKindRow.kind now explains all 16 values instead of 9.

Tests

507 pass, up from 480. New: out-of-order tool_result; multi-block tool_result
entry; an equivalence oracle pinning survey_failures.total to the failed
ToolResultEntry blocks over the same fixtures; a negative-collision table
(ModuleNotFoundError is not network, `column "x" does not exist` is not
bad_path, a quoted cascade marker is not cascade); kinds x include_cascade;
ScannerError degradation asserting both the flag and completeness; errors_only
context composition across all three roles; and the survey_failures MCP tool at
the registration layer. test_every_kind_has_a_category asserted
`isinstance(kind.category, FailureCategory)`, which cannot fail — it now
asserts map membership, and a sibling asserts every kind is producible by a
rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kor6NdZegwKxn55hdvZiot
…ilures axis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kor6NdZegwKxn55hdvZiot
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cc-explorer: make failure a queryable axis (errors_only filter + survey_failures tool)

1 participant