Repository navigation
Shared eval runner with a preflight gate: stop scoring scenarios when the environment is broken, and make a new eval a fixture instead of a copied harness - #1778
Conversation
Code Review: PR #1778 — Shared eval runner with a preflight gateReview Type: Initial Review (single commit Requirements Check
Assessment of the Deliberate DeviationsDeviation 1 — no committed baseline (AC #4). Sound; endorse. Committing The residual gap is real but small: comparison is now opt-in, so a run that forgets Deviation 2 — the 4th preflight check + proto/daemon/CLI change. Belongs here; endorse. The check was added because this branch's own eval runs wrote schemas and chat nodes into The proto/daemon/CLI change is likewise load-bearing rather than opportunistic: AC #2 and AC #5 both require Code Review Findings🔴 Critical1. 📁 This is the highest-value item. Scenarios 1 and 2 are The main agent hit precisely this during testing — a run reported "9/12 scenarios failed" from a dead daemon, meaning 3 scored as passes from a corpse. The preflight gate proves the environment was good at t=0. It does not prove it stayed good, and an eval run is 8+ minutes. The principle at stake is the issue's own: an eval that can silently score a broken environment is worse than no eval, because its output gets cited. Suggested fix — a consecutive-failure abort in the run loop: let consecutiveFailures = 0;
// inside the scenario loop, after `const scored = runTurn(...)`:
if (scored.reply.startsWith("(send failed:")) {
if (++consecutiveFailures >= 2) {
throw new EnvironmentError(
`${consecutiveFailures} consecutive turns failed to send — the daemon went away mid-run.`,
`Partial scores from an aborted run are not comparable to a complete one, so none were written.`,
);
}
} else {
consecutiveFailures = 0;
}The existing 🟡 Important2. 📁 Both error paths return For the other three checks, failing open is merely a missed detection. For this one it is irreversible damage to live user data — the specific harm that motivated adding the check. A guard against an unrecoverable outcome should fail closed. Recommend throwing Secondary: the check only catches 3. 📁
The check exists so scores are not filed against a model that did not produce them — quantization changes tool-calling behavior materially. Since 4. 📁
Mitigating: Lock ordering is safe — every site touching Suggest caching the spec at load time alongside 5. 📁 An 🟢 Suggestions6. 📁 7. 📁 8. 📁 9. 📁 10. 📁 11. 📁 12. Nit: 📁 13. Nit: 📁 Verification performed
RecommendationREQUEST CHANGESThis is a strong, well-reasoned PR and a clear net improvement: it eliminates five duplicated concerns, makes adding an eval a fixture file, ports both evals with verified byte-for-byte scoring fidelity, and closes the silent-corruption hole the issue was written about. Both deviations from the issue text are well-argued and endorsed. Two items should land before merge, both in the load-bearing preflight/runner path:
🟡 #3, #4, and #5 are worth addressing but would not block on their own — #4 is latent rather than active, since nothing polls that RPC today. The 🟢 items are polish and follow-up material. No architectural regression, and no project standard violated — no |
…n daemon death Reworks the WIP checkpoint into its final form and addresses the remaining review findings from PR #1778. 🔴 #1 — mid-run daemon death scored noTools scenarios as passes. Preflight proved the environment good at t=0 but not that it stayed good, and a run takes 8+ minutes. A daemon dying mid-group left every subsequent turn calling no tools, so negative assertions went green — the exact false result this harness exists to prevent, relocated past the gate. TurnRecord now carries `sendFailed` (a turn that never reached the model is not the same as a turn that called no tools), and two consecutive failed sends abort the run as an environment error. Verified against a live daemon killed mid-group: exit 2, 10 scored scenarios discarded, no results file. The same case previously reported "9/12 scenarios failed" with exit 1. 🟡 #2 — readServedDatabasePath failed open. Every other check failing open costs a missed detection; this one guards against writes into live user data that cannot be undone, so "I could not determine which database this is" must stop the run. Now throws on non-zero exit, unparseable JSON, or an empty database list. Verified the real-database refusal still wins over the model check. 🟡 #3 — substring model matching accepted a different quantization (NS_MODEL=gemma-4-e4b would match a loaded gemma-4-e4b-q8, whose tool-calling behavior differs). Now exact-match first, with a filename fallback for the case where the daemon reports a resolved GGUF path instead of a catalog id, and a warning when only the fallback matched. 🟡 #5 — get_status collapsed an engine error into "no model loaded". Added a tracing::warn! on the error arm; the degrade-don't-fail behavior is unchanged. 🟢 #6 stale doc path (agent-evals.md → agent-eval.md). 🟢 #9 the contamination guard now selects fixtures by whether they declare scenario prompts, so a shared helper dropped in fixtures/ is skipped rather than failing the build with a misleading parser-drift message. Re-verified the guard still fires on a planted example and is not vacuous. 🟢 #11 CLAUDE.md records that tests under scripts/ must stay DOM-free, so the test:scripts exception cannot quietly widen. 🟢 #12 documented that env.log/env.timeoutMs exist to state the contract scripts/aichat.ts consumes from the environment directly. 🟢 #13 usage errors now exit 64 rather than sharing 1 with scenario failures. Also corrected the abort footer, which claimed "no scenarios were run" when a mid-run abort had already scored some. Deferred, with reasons: - 🟡 #4 (get_status can block a tokio worker for the length of a generation) is real but latent — nothing polls that RPC today. The fix is a daemon-internal refactor beyond this PR's scope; filed as #1779. - 🟢 #7 (default --baseline path) depends on #1764 recording the honest baseline first; wiring it now would default to a file that does not exist. - 🟢 #8 (generic EvalFixture<S>) is the right shape but a refactor of a brand-new abstraction with two consumers — the reviewer's own YAGNI call. - 🟢 #10 (enforce the 6600-token constant matches N_CTX_MINIMUM) — both sites carry cross-referencing comments and the value is a floor, not an exact accounting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
Review findings addressedCommits ✅ Addressed (9)
Also fixed the abort footer, which claimed "no scenarios were run" when a mid-run abort had already scored some. Verification of the two blocking items🔴 #1 — reproduced the original bug, then confirmed the fix. Live daemon, killed mid-group (inside the 5-scenario chain, where
🟡 #2 — confirmed the destructive check still wins. With a model loaded and the daemon serving the real database via the Also re-verified the contamination guard after the #9 change: a helper file with no ⏭️ Deferred (4), with reasons
Tests
|
Code Review: PR #1778 — Re-ReviewReview Type: Re-Review Previous Review Summary
All four deferrals are legitimate. #4 is latent (nothing polls the RPC) and its fix is a daemon-internal refactor at the Requirements Check — issue #1759
The specific case the original review named — a Findings🟡 ImportantA. 📁
for (const prior of scenario.priorTurns ?? []) {
priorTurns.push(runTurn(env, chatId, prior)); // ← no sendFailed check
}
const scored = runTurn(env, chatId, scenario.prompt);
if (scored.sendFailed) { ... } // ← guardedTwo routing fixtures use
The uncaught case: a prior turn fails but the scored turn succeeds. The counter never increments, so the scenario runs against a chat that never received its context — and Also: since Fix reuses what is already written: const record = (t: TurnRecord) => {
if (t.sendFailed) {
if (++consecutiveSendFailures >= MAX_CONSECUTIVE_SEND_FAILURES) throw new EnvironmentError(...);
} else {
consecutiveSendFailures = 0;
}
return t;
};
priorTurns.push(record(runTurn(env, chatId, prior)));
const scored = record(runTurn(env, chatId, scenario.prompt));Engineering principle: a guard placed on one of two structurally identical call sites is a guard with a documented bypass. The comment on Not a merge blocker on its own. The 🔴 that motivated REQUEST CHANGES is genuinely fixed; this is a smaller, differently-shaped version on a two-fixture path. Verification of the items probed hard🔴 #1 — counter semantics, single-failure behavior, threshold. All correct.
🟡 #2 — fails closed correctly; the compatibility concern is moot. 🟡 #3 — the fallback does not reintroduce the 🟢 #9 — the guard-on-a-guard is not vacuous.
Failure modes are disjoint, each has a live assertion, and Fixture claim independently verified. Whitespace-normalized comparison against Other confirmations: Unprompted catch, noted approvingly: Tests re-run: 🟢 Nits
RecommendationAPPROVEBoth blocking items are addressed, and addressed well rather than minimally. 🔴 #1's fix distinguishes "never reached the model" from "called no tools" at the type level rather than string-matching the reply, preserves single-failure-as-real-failure, resets correctly, and routes through existing plumbing. 🟡 #2 fails closed on all three paths, and the compatibility concern turns out to be moot. Both claims I probed hard hold: fixture changes are formatting-only (verified by normalized comparison, not by reading the diff), and the #9 content-based selection is not vacuous. One residual 🟡 A: the No project standard violated: no |
… death Re-review of PR #1778 approved the fix but found the consecutive-failure guard sitting on only one of two structurally identical call sites. 🟡 A — `runTurn` is called for prior-context turns as well as the scored turn, and only the scored turn counted against the failure budget. Two routing fixtures use `priorTurns`, both loadBearing: instance-not-schema-invoice (the only fixture testing existing-type vs new-type routing) and clarification-then-fallthrough (the only fixture testing the clarification contract, with two prior turns). The uncaught case was a prior turn failing while the scored turn succeeded: the counter never incremented, so the scenario ran against a chat that never received its setup and the verdict was filed as the model's routing behavior rather than as an environment artifact. A daemon dying during a two-prior-turn setup was also only caught two scenarios later, after three failed sends. Hoisted the counting into a `turn()` helper both call sites go through, so the budget covers every send. Verified against a live daemon killed during a prior-context turn: exit 2, 7 scored scenarios discarded, no results file. Nits from the same review: - `eval_prompts(&source).1.gt(&0)` → `> 0` (reads faster; same semantics). - A fallback filename match now records `modelMatchedByPath` in the results file's provenance, so the "exact build unconfirmed" caveat outlives the terminal session that saw the stderr warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
Re-review residual addressed —
|
Both agent evals independently implemented the same five concerns — the NS_* environment contract, argv parsing, results assembly, the summary table, and baseline comparison — and neither could tell a real result from a broken environment. Shared runner (scripts/eval/): - runner.ts owns env, preflight, argv, chat-node lifecycle, results, summary, baseline diff, and exit codes. An eval is now a fixture module exporting scenarios plus a score function; adding one is a file in fixtures/, not another copy of the plumbing. - Both existing evals port over with their scoring logic moved verbatim, so scenario behavior is unchanged. Scenarios gained stable `id`s because baseline diffing joins on them — prompts get reworded (decontamination rewrote every one) and joining on text reads as remove-and-add. Preflight gate — the load-bearing part. Before scoring anything it asserts the environment can produce a valid result, exits 2 (distinct from a scenario failure), and writes no results file: - daemon reachable - NOT serving the real user database - the requested model is loaded, and is the one being scored - the granted context window exceeds the agent's system prompt The context check is what the issue was written about: a run once scored its two "no tools called" scenarios as PASSING while every turn died on context overflow before inference — a failed turn calls no tools, so a negative assertion goes green. It catches that in ~2s instead of ~8min of half-broken inference. The database check was added after this branch's own eval runs wrote schemas and chat nodes into ~/.nodespace. NODESPACED_DB_PATH — what the old reproduce steps prescribed — is overridden by the database registry (ADR-053), so the daemon serves live user data while looking correctly configured. NODESPACE_HOME is the variable that actually isolates; the gate now refuses to run without it. Granted n_ctx over the wire: - LocalAgentStatusResponse gains model_id and granted_n_ctx; get_status reports the catalog id the model was loaded by (not the resolved GGUF path, which no requested-id substring matches) and the window the engine actually allocated. - New `nodespace model status [--json]` surfaces it. Previously this value existed only in-process and had to be scraped out of daemon log text. Provenance in every results file — model, timestamp, host RAM, granted n_ctx, commit, and dirty flag — so a cited number carries the conditions that produced it. No blessed baseline is committed: the evals are nondeterministic and host-specific, #1764 owns recording the honest one, and #1775's fixes will move it. Comparison is first-class via --baseline instead. Contamination guard now enumerates scripts/eval/fixtures/ rather than naming two files, so an eval added later is guarded automatically. Verified it still fires on a planted example from the new location and is not vacuous. test:scripts runs the scoring unit test (21 tests, ~20ms, no model or daemon) and joins test:all. It ran nowhere before. This is a deliberate exception to the "never use bun test" rule, which exists to stop DOM tests bypassing the Happy-DOM vitest config — not applicable to a standalone script test. Eval setup moved to nodespace-docs/development/agent-eval.md and scripts/aichat-1329-trial.md deleted, per the docs standard. The evals themselves stay out of test:all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
Critical #1: mark failed sends with sendFailed and abort the run after two consecutive failures, so a daemon that dies mid-run cannot score noTools assertions as passes. Important #2: readServedDatabasePath now fails CLOSED — an undeterminable database path stops the run instead of skipping the check that guards live user data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n daemon death Reworks the WIP checkpoint into its final form and addresses the remaining review findings from PR #1778. 🔴 #1 — mid-run daemon death scored noTools scenarios as passes. Preflight proved the environment good at t=0 but not that it stayed good, and a run takes 8+ minutes. A daemon dying mid-group left every subsequent turn calling no tools, so negative assertions went green — the exact false result this harness exists to prevent, relocated past the gate. TurnRecord now carries `sendFailed` (a turn that never reached the model is not the same as a turn that called no tools), and two consecutive failed sends abort the run as an environment error. Verified against a live daemon killed mid-group: exit 2, 10 scored scenarios discarded, no results file. The same case previously reported "9/12 scenarios failed" with exit 1. 🟡 #2 — readServedDatabasePath failed open. Every other check failing open costs a missed detection; this one guards against writes into live user data that cannot be undone, so "I could not determine which database this is" must stop the run. Now throws on non-zero exit, unparseable JSON, or an empty database list. Verified the real-database refusal still wins over the model check. 🟡 #3 — substring model matching accepted a different quantization (NS_MODEL=gemma-4-e4b would match a loaded gemma-4-e4b-q8, whose tool-calling behavior differs). Now exact-match first, with a filename fallback for the case where the daemon reports a resolved GGUF path instead of a catalog id, and a warning when only the fallback matched. 🟡 #5 — get_status collapsed an engine error into "no model loaded". Added a tracing::warn! on the error arm; the degrade-don't-fail behavior is unchanged. 🟢 #6 stale doc path (agent-evals.md → agent-eval.md). 🟢 #9 the contamination guard now selects fixtures by whether they declare scenario prompts, so a shared helper dropped in fixtures/ is skipped rather than failing the build with a misleading parser-drift message. Re-verified the guard still fires on a planted example and is not vacuous. 🟢 #11 CLAUDE.md records that tests under scripts/ must stay DOM-free, so the test:scripts exception cannot quietly widen. 🟢 #12 documented that env.log/env.timeoutMs exist to state the contract scripts/aichat.ts consumes from the environment directly. 🟢 #13 usage errors now exit 64 rather than sharing 1 with scenario failures. Also corrected the abort footer, which claimed "no scenarios were run" when a mid-run abort had already scored some. Deferred, with reasons: - 🟡 #4 (get_status can block a tokio worker for the length of a generation) is real but latent — nothing polls that RPC today. The fix is a daemon-internal refactor beyond this PR's scope; filed as #1779. - 🟢 #7 (default --baseline path) depends on #1764 recording the honest baseline first; wiring it now would default to a file that does not exist. - 🟢 #8 (generic EvalFixture<S>) is the right shape but a refactor of a brand-new abstraction with two consumers — the reviewer's own YAGNI call. - 🟢 #10 (enforce the 6600-token constant matches N_CTX_MINIMUM) — both sites carry cross-referencing comments and the value is a floor, not an exact accounting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
… death Re-review of PR #1778 approved the fix but found the consecutive-failure guard sitting on only one of two structurally identical call sites. 🟡 A — `runTurn` is called for prior-context turns as well as the scored turn, and only the scored turn counted against the failure budget. Two routing fixtures use `priorTurns`, both loadBearing: instance-not-schema-invoice (the only fixture testing existing-type vs new-type routing) and clarification-then-fallthrough (the only fixture testing the clarification contract, with two prior turns). The uncaught case was a prior turn failing while the scored turn succeeded: the counter never incremented, so the scenario ran against a chat that never received its setup and the verdict was filed as the model's routing behavior rather than as an environment artifact. A daemon dying during a two-prior-turn setup was also only caught two scenarios later, after three failed sends. Hoisted the counting into a `turn()` helper both call sites go through, so the budget covers every send. Verified against a live daemon killed during a prior-context turn: exit 2, 7 scored scenarios discarded, no results file. Nits from the same review: - `eval_prompts(&source).1.gt(&0)` → `> 0` (reads faster; same semantics). - A fallback filename match now records `modelMatchedByPath` in the results file's provenance, so the "exact build unconfirmed" caveat outlives the terminal session that saw the stderr warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
a61c027 to
dbd5420
Compare
Clean end-to-end verification run ✅Rebased onto Result: 9/12 — reproduces the honest baseline exactlyThe three failures are scenarios 4, 6, and 8c — the same three as PR #1763's decontaminated run, and exactly the defects tracked in #1775 (4 and 8c) and #1637 (6). An independent reproduction of the known-honest baseline is good evidence the ported harness scores identically to the originals. Provenance recorded (AC #5){
"model": "gemma-4-e4b-q4km",
"recordedAt": "2026-07-28T16:50:11.762Z",
"hostMemoryGb": 17.2,
"nCtx": 32768,
"evalCommit": "dbd54200",
"dirty": false
}
Baseline comparison (AC #4)All four transition states exercised against a synthetic prior run: Environment discipline heldDaemon logged Every acceptance criterion is now verified by execution rather than inspection. |
…d fields, and calls with empty arguments (#1781) * Report argument-validation failures so the model can locate and repair them (#1775) The aichat-matrix failures in scenarios 3/4 and 8c are one defect, not two: the model constructs an invalid tool call, and the error it gets back does not say what to fix, so its retries degrade the arguments instead of repairing them. Ground truth from the daemon log — four consecutive create_schema retries: fields[1] missing "type" -> "missing field `type`" (retry) same shape -> "missing field `type`" (retry) fields[0] lost "name" -> "missing field `name`" (retry) both fields nameless -> "missing field `name`" Only the second entry was ever wrong; by the fourth attempt the model had corrupted the entry that started out correct. Serde reports the absent key with no position, so there is nothing in the message identifying which element to fix. Three changes, all at the reporting seam: - schema/mod.rs: locate malformed `fields` / `add_fields` entries before serde sees them, naming the index and the entry's own name, reporting every bad entry at once, and instructing the caller to leave the others untouched. - tools.rs: format schema errors with Display rather than Debug. `{:?}` wrapped the guidance in `InvalidParams("...")`, burying the repair instruction the message already carried — the comment there claimed the error was returned "so the model can self-correct", which the formatting defeated. - agent_loop.rs: stop substituting `{}` for unparseable arguments. That sent the tool a payload the model never wrote, surfacing as a missing-required-field error that described the substitute rather than the malformed JSON. An argument-less call still passes through, since `{}` is the faithful reading. No guidance rules were added. The rules for both failures already exist (agent_guidance.rs:25-26, the create_schema tool schema) and were in-prompt when the model emitted the bad calls; restating them is the degenerate move #1760 was filed to prevent. Also fixes the stale doc comment on MarkdownError claiming the agent formats with `{:?}`, and notes that Display text reaches the model verbatim. Found while investigating, filed separately as #1777: description-only create_schema fails for every inferred field, and the two create_schema routes disagree on whether field names carry a `custom:` prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Record why the #1775 matrix re-run produced no usable score The re-run ran against a purged DB and a daemon rebuilt with the fix, but the host had three concurrent agent sessions contending for 16GB and one GPU — two nodespaced daemons resident at ~7.4GB and ~5.3GB, plus a full cargo build under a second user. Scenario 1 hit the 240s timeout, inference reported "no model loaded", and a model reporting "loaded" still returned no assistant reply. Recording the contention rather than a number: a score measured under that load would describe the machine, not the agent. score and effective_passed are left at their previous values so nothing downstream reads an unverified result. Also notes that the no-model-loaded mode silently PASSES the noTools scenarios, since they assert the absence of tool calls and inference never ran — the same false-pass class this file already documents for scenario 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Tell the caller which fields a title_template needs (#1775) The matrix re-run surfaced a shape the first fix missed. The model sent: {"name":"Equipment Check-out Tracker","title_template":"{equipment} ({status})"} with no `fields` array at all. `describe_malformed_fields` inspects entries WITHIN that array, so an absent array passed straight through, and the request died at the title_template validator with no equipment schema persisted — which is why scenario 4 still had nothing to instantiate. The existing validator error was already correct and, after the Display fix, readable: "title_template references undefined field 'equipment' — add it to schema.fields". It states the rule but not the edit, and reports one placeholder per round-trip. The model made no second attempt. So `describe_unresolved_title_template` now reports every unresolved placeholder at once and spells out the entries to add ({"name":"equipment","type":"text"}), while naming only the genuinely missing ones — listing a field that is already defined is what pushes a caller into rewriting a working part of the call. Deliberately NOT wired into update_schema: there a template may reference fields already on the stored schema, which the payload alone cannot see, so pre-checking would reject valid calls. The existing validator remains the authority there, and a test pins that. Also corrects an assertion in the previous commit's tests that required the error NOT to contain a resolved placeholder — the message echoes the whole template for context, so what matters is that the field is not listed among those to add. core 1013 passed, agent 308 passed, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Record the #1775 matrix results, including what the fix did not fix Two runs on a purged DB with a daemon rebuilt from the fix, on a quiet machine. Raw 10/12 then 9/12; effective 8/12 in run 2 — unchanged from the 8/12 baseline. What is verified: - 8c passes in BOTH runs. This is the defect the issue was filed for. The log shows the empty-argument create_node now reported as unparseable arguments rather than the harness's substituted {} surfacing as "missing field `node_type`". What is not fixed, stated plainly: - Scenario 3 is a FALSE PASS in both runs and this work did not fix it. The model sends a title_template with no fields array; the error now names every unresolved placeholder and prescribes the exact entries to add, and the model still does not repair the call — in run 2 it retried with empty arguments and gave up. Better error text was necessary but not sufficient for this model on this shape. - Scenario 4 still cascades from 3, so the "instance request calls create_node" criterion is not demonstrated by scenario 4 at all. 8c/8d cover it instead, against types that do exist, and both pass. Also records measured run-to-run variance: scenario 5 flipped between runs on identical code (search_nodes -> search_semantic), the only scenario that moved. A single-run delta of 1 on this matrix is noise, so the raw count should not be read as movement — hence effective_passed is reported alongside it and left at 8. Notes that the within-array field locating, the largest part of the change, went unexercised by both runs and is covered by unit tests only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Re-run the #1775 matrix on the shared eval runner; record run 3 and a new defect Rebased onto #1778's runner and re-ran on a purged DB with the preflight gate passing. Raw 8/12, effective 7/12. The preflight gate earns its keep immediately: run against a daemon with no model loaded it exits 2, writes no results file, and prints the fix — catching exactly the silent-false-pass mode that invalidated this issue's first re-run attempt, where noTools scenarios "passed" because inference never ran. Three runs of this work now exist and they disagree: 10/12, 9/12, 8/12, on code differing only by the title_template addition between runs 1-2 and 3. Scenario 5 flipped twice across runs. The headline count is not a usable signal at this variance; the caveats say so and point at the per-scenario traces instead. Run 3 surfaced a defect the earlier runs did not: a CROSS-TURN duplicate write. 8c completes correctly with one create_node, then 8d's turn re-executes the previous turn's instruction and writes the same node again. The duplicate-call guard cannot catch this — seen_calls is per-turn and resets at the turn boundary, by design. Because the eval scrapes tools per chat node, that single stray call fails BOTH 8c and 8d, which reads as two defects and is one. Filed as #1780; it is not a regression from this fix, and the argument-error reporting behaves correctly in all three runs. Adopts the runner's richer results schema (provenance, per-turn traces) rather than continuing to hand-build the old shape. Unchanged and still stated plainly: scenario 3 is a false pass in all three runs and this work did not fix it, scenario 4 cascades from it, and the within-array field locating remains unexercised by every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Address code review: remove the diverging title_template pre-check Code review found a real defect I introduced, and it was the worst kind for this change: a confident, prescriptive, WRONG repair instruction. describe_unresolved_title_template was a second, hand-rolled implementation of a rule that already had an authority (validate_template_tokens). I verified the two disagree on real inputs by porting both algorithms: template fields pre-check said authority says "{a} {b" [a] OK (silently passed) unclosed '{' placeholder "{} {b}" [] "add {name:b,type:text}" empty '{}' placeholder "{{a}}" [a] "add {name:'{a'}" undefined field '{a' Rows 1-2 are the damaging ones. For "{} {b}" the caller follows the prescription exactly and is rejected anyway — sending the model into precisely the corrupt-the-good-parts retry loop this whole change exists to prevent. The pre-check also compared against raw field names while the authority compares post-storage names, a second divergence on the description-inferred path. The rule: one source of truth per validation rule. A second implementation with different scanning semantics is not a nicer message, it is a disagreeing validator. Reverted, and my own eval evidence already showed the addition never achieved its goal — the model received the prescriptive error and still failed to repair. Its three tests are replaced by three against the real authority, two of which pin the exact inputs where the pre-check diverged, so a future reintroduction fails. Also from the review: - get_node still formatted a MarkdownError with {:?} — the same defect fixed for create_schema/update_schema, in the same file, which made the newly documented "Display reaches the model verbatim" invariant already false in-tree. - Dropped the "Invalid parameters:" prefix from the new message; Display already writes "invalid params: ", so the model read a doubled preamble first. - Bounded consecutive unparseable-argument calls (MAX_CONSECUTIVE_PARSE_FAILURES). The duplicate guard keys on canonical args, so differently-malformed retries never match and could burn every iteration with zero tool executions. Set to 2, not 1: one malformed call then a correct retry is normal recovery and observed in run 3. core 1013 passed, agent 309 passed, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Note in the results that the title_template pre-check was reverted Runs 2 and 3 measured a pre-check that no longer exists. The recorded scenario-3 outcome is unchanged — the model still failed to repair — but the caveat claimed a mechanism (all placeholders reported, entries prescribed) that was removed in review for disagreeing with the authoritative validator. Says so plainly rather than leaving a caveat describing code that is not in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr * Address review nit: stop shadowing the array-name parameter `key` names the array being validated ("fields" / "add_fields") and is used as that outside the loop, but the inner loop rebound it to a per-entry property name. One identifier, two meanings, in a function whose whole purpose is naming things precisely — renamed to `required_key`. Skipped from the review, deliberately: the four pre-existing "Invalid parameters:" stutter sites at mod.rs:218/433/501/581. They predate this branch and are unrelated to argument-error reporting; fixing them here is scope creep on a change about to merge. Worth a follow-up sweep with the other Display/Debug seams. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #1759