feat(schema): validate message SHAPE statically, so the fifth summarize defect is caught offline - #136
Conversation
|
Reviewed against main The three rules are real, not decorative. I reintroduced each of the historical
The false-positive guard is the strongest part, and it is closed by construction rather than by Complexity is fine too: The findings are all in the prose, which matters more than usual here because the prose is 1. "Validates the bytes the proxy would actually send" is not true for the one wire defect this repo has paid for
Byte-different wires, one a guaranteed This is not hypothetical — Either narrow the comment, or close it in three lines, since the check is a raw-body predicate // in TestSummarizeEmittedWireIsShapeValid, after obtaining `out`:
for i, m := range gjson.GetBytes(out, "messages").Array() {
if r := m.Get("role").String(); r != "user" && r != "assistant" && r != "system" {
t.Errorf("messages.%d: role %q reaches the Anthropic wire; normalize() would hide this", i, r)
}
}Related, for merge sequencing: #137's 2.
|
| Defect | Caught |
|---|---|
tool_result with no preceding tool_use |
yes — paired-tool-result, names the id |
tool_use with no following tool_result |
yes — answered-tool-use |
tool_use_id referenced after its message was dropped |
yes |
| system-role position | yes |
role:"tool" on the Anthropic wire |
no — structurally invisible (item 1) |
| consecutive same-role | no — correctly omitted |
empty content array / empty text block |
no — genuine gap |
first message not user; trailing assistant |
no |
orphaned <<cg:HASH>> marker |
no — out of scope (needs the Store) |
Two omissions are right and worth recording so nobody "fixes" them: consecutive same-role must
not be checked, because summarize's own legal output is [msgs[0], summary(user), user-tail…]
— consecutive user messages, so an alternation rule would reject the very output this PR blesses.
Orphaned markers would drag a Store dependency into schema.
The real gap is empty content — Anthropic 400s on it, a component rewriting a message to ""
reaches it, and I confirmed it live by mutation. ~4 lines, no walk, no alloc:
if m.Role != schemas.ChatMessageRoleTool && m.Content != nil && strings.TrimSpace(MessageText(m)) == "" &&
(m.ChatAssistantMessage == nil || len(m.ChatAssistantMessage.ToolCalls) == 0) {
out = append(out, ShapeViolation{Index: i, Rule: RuleNonEmptyContent, Msg: "content must be non-empty"})
}Otherwise add a "NOT CHECKED, and why" list — the file already does exactly that for e9bf3a7, so
the omission is conspicuous given the prose quality elsewhere.
Smaller items
- The commit message's vacuity table mislabels row 2. It says
e7d1aa8 naive span boundary → FAIL offload (answered-tool-use).answered-tool-useis the head half; the span boundary
alone passes. Substance right, label points at the wrong edit — would cost the next reproducer
time. One word. - The 40-line rationale renders nowhere.
schema/validate.go:10-48sits afterpackage schema
and after the imports, separated from// ShapeViolation is…by a blank line at:49— so godoc
attaches it to nothing and pkg.go.dev shows none of it, on a badged public package. Move it to
schema/doc.goas the package comment (the package has none today) or delete the blank line.
This is the highest-value docs fix here, ahead of the two below. docs/design.md:18enumeratesschema's exports rather than describing it abstractly, so a
new exported entry point is a gap by that doc's own convention. Append
`ValidateShape` (static message-shape validation)to theschema/row.docs/components/summarize.mdhas zero mentions of shape, invariants or 400s
(grep -in 'shape\|invariant\|400'→ empty), so a short "Shape invariants" section there is
net-new and worth having.- Near-duplicate, leave it:
dropOrphanedToolResults(summarize_pairing.go:30) accumulates a
seenset of call ids the same wayseenCalldoes. Not interchangeable — one repairs, one
reports, and the repairer deliberately lets a result answer any earlier call while the validator
requires contiguity. If a third copy appears, that is the pair to unify.
One thing worth calling out as a credit: the commit volunteers that reverting the orphan repair
alone still passes, with the correct reason. I confirmed that independently. An honest negative
result is what makes the rest of the table believable.
Verdict
Mergeable with nits. This closes a blind spot the project paid for four times in live requests.
Nothing blocks merge. Only items 1 and 2 would I want fixed before this is wired into the request
path, and item 2 is a hard prerequisite for that.
…d stop the wire overclaim Review of #136 by @OsherElhadad. Four substantive findings, all addressed; the reviewer's own measurements are taken as the record and reconciled with rather than re-argued. DIALECT GATE (the one hard prerequisite). ValidateShape took no provider, and system-position is Anthropic's rule, not a property of the Anthropic-style tool protocol the docstring claimed scope over. OpenAI imposes NO positional constraint on system/developer messages, and /compact defaults to OpenAI (proxy/proxy.go:566) -- so the rule reports a violation on every OpenAI turn where the client re-injects a system message. Zero impact while nothing outside tests calls it, but `schema` is public API with a pkg.go.dev badge, so an external importer hits it unwarned, and wired to the request path it would revert those requests and silently lose their savings: a savings regression dressed as a safety check. ValidateShapeFor(provider, msgs) does the work, gating system-position on provider == Anthropic ValidateShape(msgs) the Anthropic-dialect shorthand, delegating The two PAIRING rules are protocol properties and stay ungated for every provider -- asserted explicitly, because a gate that swept them up with it would trade one wrong answer for a worse one. EMPTY CONTENT is now a rule (non-empty-content), Anthropic-gated. A blank text block is a hard 400, and ~20 SetMessageText call sites can produce one. Worth recording what the mutation actually showed: `summarize` itself is NOT one of them -- it refuses a blank summary (summarize.go:201), and making its model return "" makes it DECLINE rather than emit empty content. So the rule guards the other rewriters (cmdfilter, dedup, skeleton, textclean, ...), which have no such guard, not this component. The rule is deliberately narrow, because the property the reviewer identified as the validator's strongest -- content-shape false positives being IMPOSSIBLE rather than merely absent -- is worth more than the extra coverage a looser rule would buy. MessageText cannot see an image, a thinking block or an Anthropic tool_result payload and calls every one of them blank, so the rule fires only on a Rewritable message with non-nil content, never on a role=tool message, and never on an assistant message carrying ToolCalls. Five acceptance cases pin that down. THE WIRE OVERCLAIM is closed rather than narrowed, but not where the review suggested, because there it would have been vacuous. normalize() maps a legal wire and one carrying the illegal role="tool" onto the IDENTICAL normalized list, so role legality is a property of the BYTES and is now asserted on the raw body by assertWireRolesLegal. The placement matters: the leak needs TWO components in one turn (summarize to change the count so rebuildCountChanged runs, a second to rewrite a tool message summarize KEPT so it no longer byte-matches its pre-image). Under [summarize] alone every retained tool message still byte-matches and is emitted from its original bytes, so no role can leak and the predicate cannot fail. TestSummarizeEmittedWireIsShapeValid therefore now runs BOTH [summarize] and [summarize, extract_llm], over indented JSON so the second rewrite is real, with a vacuity guard that the two-component pipeline's message count actually changed. Verified by mutation: reverting 6e503e2 leaves [summarize] passing and fails [summarize, extract_llm] on messages.3 and messages.4 -- which is exactly the reviewer's point that the normalized round-trip destroys the evidence before the validator runs. THE HOT-PATH JUSTIFICATION WAS WRONG and is rewritten to say so. The old comment claimed the walk was "not free enough to spend on every request" and that "a check that can only fail open buys no decision". Measured (the reviewer's numbers, quoted in the doc): BenchmarkValidateShape166-16 25543 ns/op 9495 B/op 121 allocs/op BenchmarkValidateShape500-16 72796 ns/op 35492 B/op 357 allocs/op BenchmarkValidateShape5000-16 801821 ns/op 307919 B/op 3508 allocs/op 73 us on a 500-message transcript is ~0.007% of a one-second provider call and less than normalize plus the tokenizer already cost per request. And fail-open IS a decision: validate the compacted body, revert on violation, forward that -- the trade this repo makes everywhere else. The real blocker was the dialect gate, which this commit removes. Still NOT wired in here; that is a separate change (a post-pipeline check in apply.Body). DOCS. The 40-line rationale rendered NOWHERE in godoc -- it sat after the imports with a blank line before the next decl, so it attached to nothing on a badged public package. Moved to schema/doc.go as the package comment, which the package previously lacked, and extended with a "NOT CHECKED, and why" list so the deliberate omissions are not mistaken for oversights: consecutive same-role must NOT be checked. summarize's own legal output is [msgs[0], summary(user), user-tail...] -- consecutive USER messages, which Anthropic accepts. An alternation rule would reject the very output this validator exists to bless. There is now a test asserting this, as a guard rail against a future reader "completing" the validator. e9bf3a7's panic no output list exists to inspect. role="tool" a bytes property; checked on the bytes instead. orphaned <<cg:HASH>> would drag a Store dependency into `schema`. first/trailing role neither is a hard rejection; a trailing assistant turn is prefill, a supported Anthropic feature. docs/design.md's schema/ row gains ValidateShapeFor/ValidateShape, per that doc's own convention of enumerating the package's exports. docs/components/summarize.md gains a "Shape invariants" section: the four 400s this component has produced, the invariants its output is now held to, and the two things that are deliberately NOT invariants. Left alone as the review advises: the near-duplicate `seen`/`seenCall` accumulation in dropOrphanedToolResults. One repairs and one reports, and the repairer deliberately lets a result answer any earlier call while the validator requires contiguity. VACUITY CHECKS -- every new assertion verified to FAIL with the code it covers reverted, on the eval box: revert 6e503e2 (apply.go) FAIL apply assertWireRolesLegal, on messages.3+4 of [summarize, extract_llm]; [summarize] alone still passes, as analyzed drop the dialect gate FAIL schema the OpenAI half of TestValidateShapeForGatesSystemPositionToAnthropic drop the non-empty-content rule FAIL schema TestValidateShapeRejects EmptyContentWithoutFalsePositives ADD an alternation rule FAIL schema 5 tests, including the new consecutive-same-role guard -- which is the demonstration that the rule must stay out Eval box: gofmt clean, go build ./... clean, go test ./... green except TestSpendSurvivesRowEviction in `dash`, which FAILS IDENTICALLY on main e88f5d8 ("month-to-date = 7, want 10") and is untouched by this change -- `dash` does not import `schema`. No production file is touched by this commit. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
fb00b51 to
5a1760b
Compare
|
Thanks — this is the most useful kind of review to get: you reproduced the defects, benchmarked the Item 2 — dialect gate: implemented, not disclaimed
The two pairing rules stay ungated for every provider, and there is now an explicit assertion to Item 1 — the overclaim: closed, but not where you put itClosed, not narrowed. One correction to the suggested patch, and I think it matters: placed literally in So the test now runs both I did not add the third Item 3 — my justification was wrong, and the comment now says soBoth halves were wrong and I've replaced them with your numbers, quoted, plus the arithmetic: 73 µs Item 4 — empty content: rule added, and the NOT CHECKED list with it
I kept it narrower than your snippet, deliberately. Your strongest observation was that content-shape The NOT CHECKED, and why list is in Smaller items
Vacuity checksEvery new assertion verified to FAIL with the code it covers reverted, on the eval box:
Eval box (Go 1.26.4, No production file is touched by either commit on this branch. One place I'd push backNothing in the review is wrong on the facts. The only thing I'd adjust is the framing of item 1's |
…ze defect is caught offline summarize has shipped four message-shape defects and every one was found REACTIVELY -- by a provider rejecting a live request or by a benchmark failing -- each masked by the one before it: 2edb9d4 400 messages.1: role 'system' must precede an 'assistant' message or end the array fb5c460 400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result` blocks e7d1aa8 400 messages.N: `tool_use` ids were found without `tool_result` blocks immediately after e9bf3a7 panic: index out of range [-1] (a transcript shorter than keep_last) The first three are properties of the MESSAGE LIST alone: no provider, no model and no traffic are needed to decide them. Nothing in the tree checked them, which is why each one had to be paid for in live requests. schema.ValidateShape checks them, and the existing summarize/apply suites now assert what the component emits. WHAT IT CHECKS, and what it deliberately does not: system-position a system-role message away from index 0 must be followed by an assistant turn, or end the array -- the provider's own wording, NOT "index 0 only". The Claude Agent SDK appends a fresh system-role message inside `messages` on every turn (its `<system-reminder>` budget line, the same messages schema.SessionHead exists for), and that traffic is ACCEPTED: apply's captured Agent-SDK fixture carries system roles at indices 1, 4 and 7. An index-0-only rule fires on ordinary live traffic and is worthless exactly where it is needed. Verified by mutation: making the rule index-0-only fails the fixture-shaped acceptance case. answered-tool-use every `tool_use` answered in the CONTIGUOUS RUN of tool messages that follows. The run matters: one Anthropic assistant message may carry several tool_use blocks answered by ONE user message, which apply.normalize splits into one synthetic role=tool message per result. Checking only msgs[i+1] reports a violation on every ordinary parallel call. Verified by mutation: restricting the scan to msgs[i+1] fails the parallel-exchange acceptance cases. paired-tool-result every `tool_result` answers a `tool_use` seen earlier. The mirror of the above, and both are checked because e7d1aa8's finding was that they are one mistake seen from either side. NOT on the request hot path, deliberately. It walks the whole transcript and allocates per exchange; that is not free enough to spend on every request, and a check that can only fail open buys no decision for the latency. It is a test-time assertion over the normalized view. NOT able to catch e9bf3a7, and the comment says so: that was a panic inside the boundary arithmetic, so there was never an output list to inspect (pipeline.runOne swallowed it into verdict=reverted). What is assertable is the property that replaced it -- a too-short transcript comes back untouched and well-formed. WIRED IN THREE PLACES: schema/validate_test.go the rules themselves, including the pre-fix transcript for each of the three defects components/offload/summarize_shape_test summarize's OUTPUT across keep_last 1..4 over the two transcript shapes that make the boundary arithmetic go wrong, plus the historical shapes apply/shape_validate_test.go end to end: the EMITTED WIRE, re- normalized, for a parallel-call Anthropic transcript; and real captured traffic (Anthropic tool-use + five Agent-SDK turns) as the false-positive guard VACUITY CHECKS -- each fix reverted on the eval box, the new tests re-run: 2edb9d4 summary role -> system FAIL offload + apply (system-position) e7d1aa8 head half (headCount 0->1) FAIL offload (answered-tool-use) -- the HEAD half is what answered-tool-use catches. The span-boundary half REVERTED ALONE passes, benignly masked by dropOrphanedToolResults. e7d1aa8 + fb5c460 (repair no-op too) FAIL offload + apply (paired-tool-result on both parallel ids, i.e. the exact wire parallel_wire_test records) fb5c460 repair no-op ALONE PASS -- honest result: with the exchange made atomic no orphan is ever produced, so dropOrphanedToolResults is the defensive net its own comment claims to be and nothing observable depends on it e9bf3a7 clamp removed FAIL offload (panics, as it did in production) Five mutations of the validator itself were also run, each failing the test that covers it, so no rule is decorative. Full suite on the eval box: go build ./... clean, go test ./... green, gofmt clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…d stop the wire overclaim Review of #136 by @OsherElhadad. Four substantive findings, all addressed; the reviewer's own measurements are taken as the record and reconciled with rather than re-argued. DIALECT GATE (the one hard prerequisite). ValidateShape took no provider, and system-position is Anthropic's rule, not a property of the Anthropic-style tool protocol the docstring claimed scope over. OpenAI imposes NO positional constraint on system/developer messages, and /compact defaults to OpenAI (proxy/proxy.go:566) -- so the rule reports a violation on every OpenAI turn where the client re-injects a system message. Zero impact while nothing outside tests calls it, but `schema` is public API with a pkg.go.dev badge, so an external importer hits it unwarned, and wired to the request path it would revert those requests and silently lose their savings: a savings regression dressed as a safety check. ValidateShapeFor(provider, msgs) does the work, gating system-position on provider == Anthropic ValidateShape(msgs) the Anthropic-dialect shorthand, delegating The two PAIRING rules are protocol properties and stay ungated for every provider -- asserted explicitly, because a gate that swept them up with it would trade one wrong answer for a worse one. EMPTY CONTENT is now a rule (non-empty-content), Anthropic-gated. A blank text block is a hard 400, and ~20 SetMessageText call sites can produce one. Worth recording what the mutation actually showed: `summarize` itself is NOT one of them -- it refuses a blank summary (summarize.go:201), and making its model return "" makes it DECLINE rather than emit empty content. So the rule guards the other rewriters (cmdfilter, dedup, skeleton, textclean, ...), which have no such guard, not this component. The rule is deliberately narrow, because the property the reviewer identified as the validator's strongest -- content-shape false positives being IMPOSSIBLE rather than merely absent -- is worth more than the extra coverage a looser rule would buy. MessageText cannot see an image, a thinking block or an Anthropic tool_result payload and calls every one of them blank, so the rule fires only on a Rewritable message with non-nil content, never on a role=tool message, and never on an assistant message carrying ToolCalls. Five acceptance cases pin that down. THE WIRE OVERCLAIM is closed rather than narrowed, but not where the review suggested, because there it would have been vacuous. normalize() maps a legal wire and one carrying the illegal role="tool" onto the IDENTICAL normalized list, so role legality is a property of the BYTES and is now asserted on the raw body by assertWireRolesLegal. The placement matters: the leak needs TWO components in one turn (summarize to change the count so rebuildCountChanged runs, a second to rewrite a tool message summarize KEPT so it no longer byte-matches its pre-image). Under [summarize] alone every retained tool message still byte-matches and is emitted from its original bytes, so no role can leak and the predicate cannot fail. TestSummarizeEmittedWireIsShapeValid therefore now runs BOTH [summarize] and [summarize, extract_llm], over indented JSON so the second rewrite is real, with a vacuity guard that the two-component pipeline's message count actually changed. Verified by mutation: reverting 6e503e2 leaves [summarize] passing and fails [summarize, extract_llm] on messages.3 and messages.4 -- which is exactly the reviewer's point that the normalized round-trip destroys the evidence before the validator runs. THE HOT-PATH JUSTIFICATION WAS WRONG and is rewritten to say so. The old comment claimed the walk was "not free enough to spend on every request" and that "a check that can only fail open buys no decision". Measured (the reviewer's numbers, quoted in the doc): BenchmarkValidateShape166-16 25543 ns/op 9495 B/op 121 allocs/op BenchmarkValidateShape500-16 72796 ns/op 35492 B/op 357 allocs/op BenchmarkValidateShape5000-16 801821 ns/op 307919 B/op 3508 allocs/op 73 us on a 500-message transcript is ~0.007% of a one-second provider call and less than normalize plus the tokenizer already cost per request. And fail-open IS a decision: validate the compacted body, revert on violation, forward that -- the trade this repo makes everywhere else. The real blocker was the dialect gate, which this commit removes. Still NOT wired in here; that is a separate change (a post-pipeline check in apply.Body). DOCS. The 40-line rationale rendered NOWHERE in godoc -- it sat after the imports with a blank line before the next decl, so it attached to nothing on a badged public package. Moved to schema/doc.go as the package comment, which the package previously lacked, and extended with a "NOT CHECKED, and why" list so the deliberate omissions are not mistaken for oversights: consecutive same-role must NOT be checked. summarize's own legal output is [msgs[0], summary(user), user-tail...] -- consecutive USER messages, which Anthropic accepts. An alternation rule would reject the very output this validator exists to bless. There is now a test asserting this, as a guard rail against a future reader "completing" the validator. e9bf3a7's panic no output list exists to inspect. role="tool" a bytes property; checked on the bytes instead. orphaned <<cg:HASH>> would drag a Store dependency into `schema`. first/trailing role neither is a hard rejection; a trailing assistant turn is prefill, a supported Anthropic feature. docs/design.md's schema/ row gains ValidateShapeFor/ValidateShape, per that doc's own convention of enumerating the package's exports. docs/components/summarize.md gains a "Shape invariants" section: the four 400s this component has produced, the invariants its output is now held to, and the two things that are deliberately NOT invariants. Left alone as the review advises: the near-duplicate `seen`/`seenCall` accumulation in dropOrphanedToolResults. One repairs and one reports, and the repairer deliberately lets a result answer any earlier call while the validator requires contiguity. VACUITY CHECKS -- every new assertion verified to FAIL with the code it covers reverted, on the eval box: revert 6e503e2 (apply.go) FAIL apply assertWireRolesLegal, on messages.3+4 of [summarize, extract_llm]; [summarize] alone still passes, as analyzed drop the dialect gate FAIL schema the OpenAI half of TestValidateShapeForGatesSystemPositionToAnthropic drop the non-empty-content rule FAIL schema TestValidateShapeRejects EmptyContentWithoutFalsePositives ADD an alternation rule FAIL schema 5 tests, including the new consecutive-same-role guard -- which is the demonstration that the rule must stay out Eval box: gofmt clean, go build ./... clean, go test ./... green except TestSpendSurvivesRowEviction in `dash`, which FAILS IDENTICALLY on main e88f5d8 ("month-to-date = 7, want 10") and is untouched by this change -- `dash` does not import `schema`. No production file is touched by this commit. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
5a1760b to
df08dab
Compare
|
Rebased onto Re-verified against main's six new commits, which matters here because this PR's suites assert on That last line is worth stating explicitly: earlier reports on this PR — including mine — said the suite
|
Why
summarizehas shipped four message-shape defects, and every one was found reactively — a provider rejecting a live request, or a benchmark failing — each masked by the one before it:2edb9d4400 messages.1: role 'system' must precede an 'assistant' message or end the arrayfb5c460400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result` blockse7d1aa8400 messages.N: `tool_use` ids were found without `tool_result` blocks immediately aftere9bf3a7panic: index out of range [-1]on a transcript shorter thankeep_lastThe first three are properties of the message list alone — no provider, no model, no traffic needed to decide them. Nothing in the tree checked them, which is why each had to be paid for in live requests, and nothing would have caught a fifth.
schema.ValidateShapechecks them, and thesummarize/applysuites now assert what the component emits.What the validator checks
system-position— a system-role message away from index 0 must be followed by an assistant turn, or end the array.answered-tool-use— everytool_useis answered within the contiguous run of tool messages that follows. The run matters: one Anthropic assistant message may carry severaltool_useblocks answered by ONE user message, whichapply.normalizesplits into one syntheticrole=toolmessage per result, so checking onlymsgs[i+1]reports a violation on every ordinary parallel call.paired-tool-result— everytool_resultanswers atool_useseen earlier. The mirror of the above; both are checked becausee7d1aa8's finding was that they are one mistake seen from either side, and fixing one alone leaves the other live.Not on the request hot path, deliberately. It walks the whole transcript and allocates per exchange — not free enough to spend on every request, and a check that can only fail open buys no decision for the latency. Putting it there is a different proposal.
It cannot catch
e9bf3a7, and the code says so: that was a panic inside the boundary arithmetic, so there was never an output list to inspect (andpipeline.runOneswallowed it intoverdict=reverted). What is assertable is the property that replaced it — a too-short transcript comes back untouched and well-formed.Where it is wired
schema/validate_test.gocomponents/offload/summarize_shape_test.gosummarize's OUTPUT acrosskeep_last1..4 over the two transcript shapes that make the boundary arithmetic go wrong (system-prompt head + parallel exchange; assistant tool-call head), plus the three historical output shapesapply/shape_validate_test.goRules from the original draft that I dropped, and why
main. The Claude Agent SDK appends a fresh system-role message insidemessageson every turn (its<system-reminder><total_tokens>N tokens left</total_tokens>budget line — the same messagesschema.SessionHeadexists to ignore), and that traffic is accepted:apply/testdata/session_head_agentsdk.json, real captured proxy traffic, carries system roles at indices 1, 4 and 7 of five- and eight-message transcripts, each followed by an assistant turn or ending the array. An index-0-only rule fires on ordinary live traffic — the same trap as checking onlymsgs[i+1]for a parallel call. The rule implemented is the provider's own wording, which still rejects the2edb9d4shape because the summary was spliced in front of the kept tail, which begins with a user turn or a tool exchange, not an assistant reply.tool_resultmust immediately follow its call" rule — not added.main'sdropOrphanedToolResultsexplicitly documents distance-pairing as legal ("a summary can legitimately sit between the two"), so such a rule would flagmain's own repaired output. Note the tension for the record: the provider requires immediacy, so a distance-paired result thatdropOrphanedToolResultskeeps would still be rejected upstream. It is unreachable today —summarizeSpan's atomicity walk (and the replayed boundary's identical walk) means the kept tail never begins on a tool message — so this is an observation, not a claimed live defect.answered-tool-usedoes enforce immediacy from the call's side.Vacuity checks (the standing rule)
Each fix reverted on the eval box, the new tests re-run:
2edb9d4summary role →systemsystem-position)e7d1aa8naive span boundaryanswered-tool-useon the assistant tool-call head, atkeep_last1 and 2)e7d1aa8+fb5c460(repair also no-op'd)paired-tool-resulton both parallel ids — the exact wireparallel_wire_testrecords:[user, summary, tool_result pa_h, tool_result pb_h, user])fb5c460repair no-op'd alonedropOrphanedToolResultsreally is the defensive net its own comment claims to be, and nothing observable depends on it. It is only reachable with the atomicity fix also reverted, as above.e9bf3a7clamp removedTestSummarizeLeavesAShortTranscriptShapeValidpanics withindex out of range [-1], as production did (that test deliberately does notrecover())Five mutations of the validator itself were also run, each failing the test that covers it, so no rule is decorative:
system-position→ fails the system-role-summary testpaired-tool-result→ fails the orphaned-result testanswered-tool-use→ fails the unanswered-call testmsgs[i+1]instead of the tool run → fails the parallel-exchange acceptance cases (1 and 3 false positives)system-positionindex-0-only (the original draft's rule) → fails the Agent-SDK acceptance case with 3 false positivesBuild and test
Eval box (
go1.26.4,CGO_ENABLED=1):gofmtclean,go build ./...clean,go test ./...green — 27 packages, 0 failures. No benchmarks run.