Skip to content

fix: make agent recording fail loudly and actionably, not silently - #32

Merged
mikehasa merged 10 commits into
mainfrom
fix/recording-robustness
Jul 30, 2026
Merged

fix: make agent recording fail loudly and actionably, not silently#32
mikehasa merged 10 commits into
mainfrom
fix/recording-robustness

Conversation

@mikehasa

Copy link
Copy Markdown
Owner

What

Two related improvements to the recording path, both surfaced by a single failed agentacct_record_section call and the audit that followed it. Grouped here because both are about the same thing: an agent's recording should not fail silently or fail for a reason nobody can act on. They touch disjoint files.

1. MCP input-surface hardening (mcp.py, install_guide.py, …)

Five real rejection causes, measured against the maintainer's own transcripts:

  • We shipped an instruction our own schema rejects. The recording contract told agents to send title; the tool accepted only section_title and hard-rejected the whole call (hit in 48 sessions). title is now an accepted alias, and the instruction is corrected. The alias keeps already-onboarded machines working without re-running onboard, since the rendered instruction files are never refreshed.
  • Limit errors now state what was received, not only what is allowed — so an agent no longer has to shrink blindly and retry (the original incident: five wasted retries).
  • The metadata byte cap was a CJK trap. It measured an ASCII-escaped encoding, so each CJK character counted as 6 bytes and each emoji as 12 — a Chinese-writing agent was refused at roughly a third of the advertised budget, by an error naming a parameter it had never sent. Now measured in real UTF-8 bytes on all three write surfaces, and the error names the field that actually overflowed.
  • The files project-relative rule (the single largest rejection cause) is now published in the schema, and an absolute path that provably lies under the call's own project_dir is normalized instead of failing the whole call. Paths that escape the project are still rejected — verified against a 43-shape path matrix plus a hostile-Windows matrix and a real on-disk symlink to /etc.
  • A tool call mangled in transit — parameters absorbed into a narrative field as literal text — is now flagged with a warning and a marker instead of being stored silently. It never rejects and never repairs. The detector was calibrated against the real ledger: it fires on 2 of 3535 real events, both true positives, zero false positives.

2. Refused-recording visibility (log_evidence.py, …)

A refused recording call previously left no trace anywhere — no store entry, no counter, no log line — so an agent that failed to record was invisible to both the user and the maintainer, while the dashboard told the user to record more work. Local logs now shows the calls agentacct refused, with counts and a fixed set of reason codes. The figure is derived at read time from data already on disk, so it covers refusals that predate this release (~1070 across 440 sessions on the maintainer's machine), and it stores only counts and reason codes — never the offending value or path (the rejection path runs before redaction). A client-layer timeout is deliberately not counted as a refusal. The "usage without work context" prompt no longer blames the user for the part agentacct itself caused.

Verification

  • Full suite green (.venv/bin/pytest -q): 2954 passed, 1 skipped.
  • Each change is pinned by new tests (tests/test_mcp.py, tests/test_refused_recording_attempts.py), each proven non-vacuous (breaking the production line fails exactly its test). Path containment, CJK cross-surface agreement, and the mangle-detector calibration were re-measured by an independent review pass.

mikehasa added 10 commits July 31, 2026 00:01
…uses

All five were measured against the user's own transcripts and ledger.

1. The shipped recording contract told agents to set `title`, which
   agentacct_record_section rejected outright ("unexpected argument(s):
   title") - 48 distinct sessions. The HTTP lane has always called the
   field `title` and work_events has always read `section_title or
   title`, so MCP was the outlier. record_section now accepts `title` as
   an alias (section_title wins when both are supplied) and the
   instruction constant says section_title. The alias is what keeps
   already-onboarded machines working: rendered CLAUDE.md / AGENTS.md
   blocks are written once at onboard and never refreshed.

2. Limit errors stated the limit but not what arrived, so a caller could
   only shrink blindly (the incident cost five retries: 2039 -> 1904 ->
   1664 -> 1614 -> 1477 -> 1172). A single _limit_error builder now
   reports both for the scalar string limit, the list-item length and
   the list-count limit, matching what the HTTP lane already does.

3. The metadata byte cap json-encoded with ensure_ascii=True, billing
   every CJK character 6 bytes and every emoji 12: a summary of 1200 CJK
   chars plus a next_step of 200 measured 8432 bytes and was rejected,
   about a third of the advertised budget, and 504 of ~1955 stored
   section summaries are CJK-heavy. The size is now measured as real
   UTF-8 bytes. The 8192 limit is unchanged (it is replicated on the
   HTTP and CLI write paths and the symmetry is load-bearing), and the
   message now names the largest offending field and its byte size
   instead of blaming a `metadata` parameter the caller never passed.

4. "files[0] must be project-relative" was the biggest single rejection
   cause (~468 sessions) and the rule appeared in no schema description,
   while the harness instructs absolute paths. The rule is now published
   on every tool that takes `files`, and an absolute path that provably
   lies under the project_dir declared on the same call is relativized
   instead of rejected. Containment is lexical and conservative: no
   filesystem access, only the explicit project_dir argument counts, and
   the '..' check runs on the relativized result so nothing can escape.
   Paths are also normalized now, closing the gap where "src/./a.py"
   passed the validator and reached the ledger un-normalized.
   machine_check.name gains an explicit maxLength so an over-long name
   fails as a name, not as "metadata must be <= 8192 bytes"; it is never
   truncated, since name feeds the check-identity hash.

5. Mangled tool calls absorb parameters into a narrative value as
   literal text. Sections and machine checks now WARN when a closing
   tag for one of that tool's own unsupplied properties appears in free
   text, and stamp a server-authored marker. Never rejected and never
   repaired - repair would fabricate fields the agent never wrote. The
   closing-tag form measured 1 true positive and 0 false positives
   across 1955 section events; bare-word and opening-tag detectors were
   rejected as unusable.
Round-2 review of 26092c0. Six findings were behaviour, one was coverage.

1. machine_check.name loses its 240-character cap entirely, schema maxLength
   included. The cap turned a 241-8000 character band that recorded fine
   before it into hard rejections, and a ~300-character name (an agent
   recording the full pytest invocation it ran) is ordinary. The cap's only
   goal was that an over-long name not be blamed on a `metadata` parameter the
   caller never passed, and the size error now reports a field and a byte
   count, which serves that goal without a cap. Measured caveat, stated in the
   code and pinned by a test rather than glossed: past the budget the error
   names the largest field, which for a machine check is the `summary` the
   server synthesizes as "<name>: <result>", so that extreme case is still one
   step removed. It is no worse than the un-named message it replaced, and the
   band that actually regressed is fixed.

2. The metadata byte MEASURE moves to storage.py, next to the other
   cross-surface validation primitive, and all three write surfaces call it.
   Measuring real UTF-8 bytes on the MCP lane alone broke the symmetry the
   shared budget exists for: metadata={"notes": "\u8bb0"*2000} is 6013 real
   bytes and 12013 escaped, so MCP accepted a payload the HTTP lane rejected.
   One helper, one 8192, one verdict per payload. allow_nan stays each lane's
   own choice, so the HTTP lane's existing NaN tolerance is unchanged, and the
   lone-surrogate fallback now protects all three rather than one.

3. files=["."] and ["./"] no longer fail the call. Both recorded fine before;
   killing a whole section or machine check to reject one cosmetic path entry
   is exactly the behaviour this validator exists to remove. The entry names no
   file, so it is dropped rather than stored. An absolute path equal to
   project_dir now behaves the same with or without its trailing slash, instead
   of one form being fatal and the other cosmetic.

4. Backslashes are folded for VALIDATION only, never into the stored value. On
   macOS and Linux `weird\name.py` is a legal filename, and the branch was
   silently rewriting it into a genuinely different `weird/name.py`. What the
   caller sent is what gets stored; only redundant "." and "/" segments, which
   denote the same path either way, are still normalized away.

5. The relativized remainder is re-validated against the same rule as any other
   value. project_dir "/repo" with files=["/repo/~/x"] stored "~/x", and
   ["/repo/C:/x"] stored "C:/x" - values the schema description this server
   publishes calls impossible.

6. `title` is excluded from the mangle detector's candidate set. Adding it as a
   record_section property widened the detector onto `</title>`, the most
   common closing tag in HTML prose. The 1-true-positive / 0-false-positive
   calibration was measured on the property set WITHOUT `title` and has not
   been re-measured, which the docstring now says outright instead of quoting a
   rate for a detector that no longer matches the one measured.

7. The four uncovered pieces of new logic get real tests: the Windows
   drive-letter prefix, the `~` project_dir guard, the lone-surrogate fallback,
   and the list-count message, whose assertion is now pinned to the exact
   string rather than to a "50"/"51" substring pair that a useless message
   would also satisfy.

Three assertions written by 26092c0 encoded the behaviour findings 1 and 3 call
wrong: the 240 cap, its schema maxLength, and files=["."] sitting in a list of
inputs expected to be rejected. They are corrected in place, not deleted, and
the replacements assert the opposite behaviour. No test predating this
workstream was touched.

Each fix was proved non-vacuous by disabling it and confirming exactly the
named test fails: 12 mutants, 12 detected. Containment was re-attacked across
42 path cases plus a real on-disk symlink out of the project; every stored
value satisfies the published rule and nothing escapes.
…fix the name band

Round-3 review of 33b61e9. Two findings were behaviour, one was an accusation
written into the stored record, one was a measured number that was wrong.

1. A Windows absolute path no longer kills the call. Recognizing drive letters
   as absolute was right -- it stopped "C:/repo/a.py" being filed as a relative
   path -- but pairing it with a rejection turned a shape that recorded fine
   before into a whole-call failure, which is the exact anti-pattern this
   validator exists to remove. Measured: record_section with
   files=["C:\repo\src\a.py"] and no project_dir stores that path on 541542e
   and errors on 33b61e9. The review named the no-project_dir shape; the same
   path with a project_dir that cannot contain it regressed identically and is
   fixed with it, because splitting them would leave a worse inconsistency than
   the one being fixed. Such a path is now kept, separators normalized, and is
   still escape-checked, so "C:\repo\..\x" stays fatal. A POSIX absolute path
   with no usable project_dir stays a rejection, as before this branch.

2. The backslash rule applies on both path branches, not one. files=
   ["weird\name.py"] stored 'weird\name.py', but files=["/repo/weird\name.py"]
   with project_dir="/repo" stored 'weird/name.py' -- the same silent merge of
   two different POSIX files that finding 4 of 33b61e9 fixed one branch over.
   The relativizer returns an OFFSET rather than a substring, so the arrived
   value and the backslash-folded copy the containment check reads are cut at
   the same index. A Windows path is the deliberate exception: there a
   backslash is a separator, so "C:\repo\src\a.py" under project_dir="C:\repo"
   still stores 'src/a.py' instead of fragmenting against the same file
   recorded with forward slashes.

3. The mangled-tool-call detector's ineligible set is calibrated, not
   hand-picked. Excluding `title` alone removed one HTML/SVG element name on
   the grounds that agents write about markup, and left `summary`, `metadata`
   and `source` -- element names by the same reasoning -- armed to stamp a
   server-authored accusation into the stored record of ordinary prose.
   Measured READ-ONLY over the 6261-event global ledger, scoped to the 3535
   events these two tools write (1956 section, 1579 evidence): `</title>`,
   `</metadata>` and `</source>` never occur, and `</summary>` occurs twice,
   both inside genuine mangled calls that DID supply `summary`, so it could
   never have fired. Excluding all four costs zero measured true positives.
   The docstring now carries those numbers in place of the stale
   "1 true positive, 0 false positives", which undercounted (there are two
   true positives, one per lane) and credited its bare-word counts to the
   section subset when they were measured over all 6261 events. It also states
   the two limits the numbers do not cover: no property name has EVER appeared
   as a closing tag in real prose, so the ledger licenses no exclusion by
   itself -- the set rests on the 2 measured `<title>` occurrences in genuine
   prose plus the vocabulary rule that generalizes them -- and only 51 of the
   3535 events mention markup at all. `client`, which fires on XML prose but is
   in neither vocabulary and has 0 occurrences here, stays eligible and is
   named in the docstring as the known gap.

4. The documented name band was wrong at the top end. Binary-searching a
   {source, name, result} call puts the largest accepted `name` at 4036
   characters and the first rejection at 4037, identically on 541542e and on
   33b61e9 -- so "241-8000" was wrong when written, not changed by the branch.
   It is roughly half of 8192 because `name` lands in the budget twice, once as
   itself and once inside the synthesized "<name>: <result>" summary. Corrected
   in the comment and the test docstring, and pinned by a test; the 33b61e9
   commit message still carries the old figure and cannot be corrected here.
   The same measurement also corrects the caveat above it: at 4037 the error
   already blames the summary (4060 bytes against the name's own 4049), so that
   indirection is every over-budget name, not an extreme case.

Behaviour changes 1 and 2 are covered by tests that fail without them; 4 is a
documentation fix, so its test characterizes the boundary rather than
regressing. tests/test_mcp.py::test_windows_drive_letter_is_absolute_for_validation
asserted the rejection finding 1 removes, so its first half is updated; it was
added by 33b61e9 and is not on main.

Suite: 2927 passed, 1 skipped.
…tor exemption

The comment justified `source`'s membership in
MANGLE_DETECTOR_INELIGIBLE_PROPERTIES by calling it "inert — it is required on
both tools, so it is never an unsupplied property". That is checkably false:
agentacct_record_machine_check declares no `required` list and reads `source`
via `_optional_limited_str(..., None)`, so a machine-check call that omits it
leaves `source` genuinely unsupplied and eligible to be suspected. The
exemption is load-bearing there, not bookkeeping.

Replace the clause with the reason that is actually true and measured, keeping
the surrounding calibration intact. Re-measured READ-ONLY over the same
6261-event global ledger, scoped to the 1956 section events and the narrative
values the detector reads: a bare-word detector fires on `source` in 170 of
them, ahead of `files` at 130 and every other property name; `</source>`
remains at 0 occurrences, as the bullet above already records. `<source>` is
also an HTML element name, so the vocabulary rule that covers `title`,
`summary` and `metadata` covers it directly.

Comment-only change; no behavior change. Suite: 2927 passed, 1 skipped.
When the MCP server rejects a recording call it persists nothing, so a user
whose agent silently fails to record sees a thin dashboard and concludes the
product does not work. The refusal is only recorded in the client's own
transcript, which the importer already reads -- and folds into the generic
evidenced_outputs_skipped counter that no surface renders.

Derive a separate "recording calls agentacct refused" figure from that same
scan, broken down by a frozen reason vocabulary and the server's own argument
names. evidenced_outputs_skipped is deliberately NOT reused as the number: it
also counts client-side rejections, wire drift, and non-created-event payloads,
which are now reported as an explicit unclassified remainder so the two figures
reconcile instead of disagreeing.

Nothing about a refusal is stored. The breakdown rides the in-memory scan
candidate only, which is what makes it retroactive: every scan re-derives it
from the transcripts still on disk, including refusals recorded long before
this existed. The rejection path runs before the redactor, so only the bounded
{tool, field, reason_code, count} triple leaves it -- never the message, the
rejected value, its length, or a path.

Surfaced on Local logs (/raw), with copy that says plainly these are attempts
agentacct refused rather than work the user failed to record; the matching
"no work context" next-step now points at it instead of blaming the user alone.
The refused-recording figure was matching the MCP-error pattern anywhere in an
output with an unanchored search, so a SUCCESSFUL call whose payload merely
QUOTED an agentacct error was counted and rendered as "agentacct refused this
call" -- and the reconciliation remainder showed 0 with no warning. An agent
recording after_summary="green after fixing MCP error -32602: files[0] must be
project-relative on ..." is real recorded work, and the one number this surface
exists to be honest about was accusing it.

Locate the error by POSITION instead: the first line (Claude Code renders the
whole tool_result as "MCP error -32602: <message>", optionally inside its
<tool_use_error> tag) and the line directly after codex's "Caused by:". Both
real wire shapes still classify; a quote sitting anywhere else in a payload no
longer does. An unmatched refusal remains a plain skip in the disclosed
remainder, so the two figures still reconcile -- they just no longer overstate.

Split the minimum bound out of value_over_limit. mcp.py rejects
input_tokens=-5 with "input_tokens must be >= 0", which the shipped classifier
reported under copy reading "Value above the allowed maximum" -- the opposite
of what happened, telling the user to shrink a number they need to raise. The
new value_under_limit code carries "Value below the allowed minimum", and a
test now pins that every frozen reason code has plain-language copy. Tool
attribution was checked for this shape on both live channels and is correct.

The surface itself was untested end to end: every test called the render helper
directly, so deleting the single line that injects the section into /raw left
the whole suite green while the feature was gone from the product. Two route
tests now GET the page -- one with refusals, one on a clean machine -- and
assert the section id, its rows, and that the pre-redaction scan still leaks
nothing at the route.

Also drop a duplicate `Mapping` import that shadowed the collections.abc one
for the whole of client_usage, with an ast test over this track's modules since
the repo runs no linter, and correct a comment claiming the legacy codex
function_call channel yields refusal text (it does not: that channel's unwrap
returns None for a plain-text error, so those calls stay an honest under-count).
The refusal figure established WHERE an error must sit but never checked WHOSE
error it was, so JSON-RPC errors raised by the MCP CLIENT layer were counted and
rendered as "recording calls agentacct refused". A tool_result reading "MCP
error -32001: Request timed out" produced refused_attempt_total=1 -- but nothing
reached this server, so nothing was refused, and the surface accused the product
of rejecting a call it never saw.

Require the located error to carry a code agentacct's own server emits.
mcp.py's dispatch answers a tools/call with -32004 (FileNotFoundError), -32602
(InvalidParams/ValueError, which is also how "Unknown tool" comes back) and
-32000 (any other exception, plus the store-less degraded server whose whole job
is to stay connected and refuse). Keyed on the code, not the wording, so
rewording a refusal message cannot change the count. -32000 is also what the MCP
client libraries use for their own ConnectionClosed, and that one collision is
resolved on the CLIENT's fixed string rather than by guessing at server prose.
Everything else stays an unclassified skip, disclosed as the remainder.

Widen the anchor without widening the search. Three real refusal shapes were
silently uncounted because only lines[0] was tried: a tool_result opening with a
blank line, Claude Code's <tool_use_error> written on a line of its own rather
than inline, and a codex cause separated from its "Caused by:" header by a blank
line. Anchor on the first line that CARRIES something instead -- still a
position, so a payload quoting an error is still not a refusal.

Require "Caused by:" to actually follow something. Sitting at index 0 it is just
the first line of some payload, and letting it nominate the second line let any
text opt itself in. Bound the code's digit run while here: this reads untrusted
transcript text, and int() on a several-thousand-digit run raises on the
interpreter's own conversion limit.

Pin the cross-file copy. work_ledger's next step sends a user to a heading
api.py renders; a test now takes the quoted section name out of the sentence,
asserts the page really renders it in both states, and asserts work_ledger keeps
exactly one copy -- so neither side can be reworded alone into a next step that
cannot be followed.
@mikehasa
mikehasa merged commit 88f6443 into main Jul 30, 2026
3 checks passed
@mikehasa
mikehasa deleted the fix/recording-robustness branch July 30, 2026 21:52
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.

1 participant