feat(run): answer the agent with a model, not a Markdown parser - #285
Merged
Conversation
#257 shipped a heuristic responder: it read the agent's last message as Markdown and answered exactly one shape, a list of options under a line ending in `?`. Replace it with the LLM answering agent #258 asks for, and retire the heuristic rather than layering the model behind it. The heuristic is not worth keeping. Its `first_option` rule fires on any list under a question line, so "Ready to proceed?" above a numbered plan is answered "Add the cache" — and telling an options list from an enumeration needs understanding, not a regex. Five more ways it misreads: a free-form question is dropped whenever another group in the message is answerable; `(Recommended)` matches anywhere in an option body; a Markdown link parses as a pre-checked box; a sub-bullet truncates a group; fenced code is read as options. A stop is loud and greppable, but a wrong answer enters the transcript as an ordinary user turn and the judge grades a conversation that never happened. Worse for what eval-magic is for: responder coverage tracked the agent's *formatting*, which is exactly what a skill changes. A skill teaching "offer options with a recommendation" put its arm on the deterministic path while the control arm stopped mid-task, making coverage a confound in the comparison. Against that, the heuristic bought a saved dispatch worth cents beside a real coding task, and a determinism the nondeterministic agent under test never had. A responder eval now declares `{ "type": "llm" }` and the runner consults a small model once after every round, through the same harness as the agent under test. The consultation is a one-shot dispatch modelled on the judge's — guard off, its own capture directory, its verdict written to a file the runner named — with one difference that matters: it runs in the cell's `responder/turn-N/`, above the task env, so it can neither write into the codebase under measurement nor inherit that codebase's CLAUDE.md as instructions to itself. The responder is shown only what the agent already knows: the opening prompt, its own prior replies, and the agent's last message. Not `expected_output`, and not the assertions — those are the grading criteria, and a responder that had read them could hand the agent the rubric. It answers `answer`, `done`, or `cannot_answer`. Because it decides `done`, completion is a judgement now rather than the absence of a question mark, and the judgement is recorded with its reason. Nothing unvouched-for is delivered. A reply that is blank, past 2000 bytes, carries a fenced code block, or repeats the previous one verbatim is not sent: the run stops with `responder_cannot_answer` and a named cause. So does a consultation that declined, failed, timed out, or wrote nothing usable. One outcome, because the run ended mid-task either way; nine causes, because an honest refusal and a broken dispatch call for different fixes. Those runs measure an interrupted task, so `aggregate` counts them per condition in benchmark.json's `validity_warnings`. Per condition is the point: one arm truncated more than the other is a threat to the comparison, not just to the run. `run --responder-model` chooses the model, run-level on purpose — per eval it would be a second uncontrolled variable — and reaches conditions.json, dispatch.json, and BASELINE.md, where the agent and judge models already are. Closes #258. Part of #244. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PmT77zVcqVKTNmYXKn5ui
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #258. Part of #244.
What changes for a user
An eval declaring a
responderused to be answered by a Markdown parser. Now it isanswered by a small model, consulted through the same harness as the agent under test:
{ "id": "add-request-caching", "prompt": "Requests to the pricing API are slow. Can you add caching?", "expected_output": "A working cache with the pricing endpoint under 100ms.", "responder": { "type": "llm", "max_turns": 8 } }Before, a question with no option list ended the run:
After, it is answered and the task finishes.
type: "heuristic"is gone; the onlyvalue is
"llm".Why the heuristic does not survive
#258 asked for the LLM responder to sit behind the heuristic's
responder_cannot_answerbranch. Investigating that branch turned up the strongeranswer: the heuristic should not be there at all.
It manufactures garbage, and does so silently.
lead_in_questiononly required theprevious non-blank line to end in
?, so:was answered "Add the cache" — to a yes/no question, with a fragment of the agent's
own plan. That one is unfixable in principle: telling an options list from an
enumeration needs understanding, not a regex. Five more misreads, all live in the
parser this PR deletes:
- Redis — the docs call this **recommended**- [x](https://…) Use the CDN(Recommended)below it-shell lines under a?A stop is loud; a wrong answer is silent.
responder_cannot_answeris greppable andwarned about by name. A false positive enters the transcript as an ordinary user turn,
and the judge grades a conversation that never happened. At campaign scale nobody reads
transcripts.
And it made responder coverage depend on the agent's formatting — which is exactly
what a skill changes. A skill teaching "offer options and mark a recommendation" put
its arm on the deterministic path while the control arm stopped mid-task. Coverage
became a confound in the with/without comparison this tool exists to make.
Against all that, the heuristic bought one saved dispatch worth cents beside a real
coding task on a real codebase, and a determinism the nondeterministic agent under test
never had.
turnsremains the zero-model path for anyone who wants an exchangeidentical in every run.
The heuristic landed on
devafterv0.9.1and never shipped in a release, so removingit costs no migration — and replacing rather than layering means the schema, docs, and
provenance churn happens once.
The consultation
A one-shot dispatch modelled on
dispatch_judge— the existing precedent for a secondmodel in the loop. Same
exec_template, guard arguments off, its own capture directory,verdict written to a file the prompt names and the runner reads.
One difference from the judge, and it is load-bearing. A task's
outputs_dirisenv_root/.eval-magic-outputs/…, inside the env. Running there would put a secondunguarded writer inside the codebase under measurement and let the responder inherit
that codebase's
CLAUDE.md/AGENTS.mdfrom cwd — contaminating the simulated user withthe project's own instructions. Consultations instead run in a new
DispatchTask.responder_dirat the cell directory'sresponder/turn-<n>/, above theenv, where the prompt and verdict also stay as an audit trail.
Stateless: no session, no resume. The agent-under-test's session is untouched, and the
existing session-id equality check keeps that honest. Concurrency is unchanged — a
consultation runs between its task's rounds, never alongside them.
What the responder is shown
Only what the agent already knows: the eval's opening
prompt, the responder's ownprior replies, and the round's final assistant message. Not
expected_outputand notthe assertions — those are the grading criteria, and a responder that had read them
could hand the agent the rubric. Pinned by a test that reads the prompt file off disk.
Bounding it this way is deliberate: #261 exists because judge prompts inline unbounded
run records, and a dispatch that fires every round must not repeat that.
The agent's message goes in verbatim, so an agent could in principle write instructions
to the responder into it. That is contained by the runner reading the verdict from the
path it chose rather than one parsed out of anything: a redirected write is a missing
verdict, which stops the run.
Verdicts, and what is never delivered
{ "verdict": "answer" | "done" | "cannot_answer", "reply": "…", "rationale": "one line" }Because the responder decides
done, completion is a judgement now rather than theabsence of a question mark — recorded with its reason, so a run that ended early is
legible.
A reply that fails validation is not sent. The run stops instead: an undelivered
reply is a loud, greppable stop, while a bad one is graded as though the exchange
really happened.
empty_replyreply_too_longreply_contains_codereply_repeatedLength and code are the same rule twice: a simulated user answers in sentences, so
anything longer means the responder started doing the agent's work, and crediting the
agent under test with work it did not do would corrupt the result. A repeat means the
exchange is circling.
A consultation that produced nothing usable stops the same way, with its own cause —
declined,dispatch_failed,dispatch_timed_out,missing_verdict,malformed_verdict. OneConversationStopReason, because the run ended mid-task eitherway; nine causes, because an honest refusal and a broken dispatch call for different
fixes. This is #258's "responder failure degrades to #257's stop reason rather than
failing the run", with the diagnosis attached:
The consultation is capped at 300s independently of the task budget. Without that, a
hung responder eats the agent's remaining time and the task is recorded as
timed_out—blaming the agent for the runner's hang.
max_turnskeeps #257's ordering: classify first, bound second, so an agent thatfinishes on its last permitted turn completed rather than ran out.
Truncated runs reach the report
A run the responder could not carry to completion measured an interrupted task, so
counting it beside a completed one biases the delta.
aggregatenow reports them inbenchmark.json'svalidity_warnings, per condition:Per condition is the point: one arm truncated more than the other is a threat to the
comparison, not just to the run.
Provenance
run --responder-modelandpromote-baseline --responder-model, beside the existingagent and judge flags. It reaches the command line through the same
{model_arg}slotand persists to
conditions.json→dispatch.json→BASELINE.md— exactly whereagent_modelandjudge_modellive, and no further (run.json,benchmark.json, andgrading.jsoncarry no model fields for any model).Run-level only, no per-eval override. Unlike
llm_judge.model, the responder is aproperty of the experiment: letting one eval answer with a different model than its
neighbours puts a second uncontrolled variable inside the comparison. Omitting the flag
answers on the harness's default model.
Every turn still records its
origin; the eval's own prompt still carries none, andthat absence is still what tells an authored turn from a derived one.
{ "type": "user_message", "ordinal": 2, "round": 2, "text": "An in-process LRU is fine.", "origin": { "responder": "llm", "rationale": "the simplest option that needs no new service" } }Cross-harness: still no descriptor field
The responder reads
TranscriptSummary::final_text, which every parser alreadynormalizes, replies through the existing
{prompt_arg}slot, and dispatches itsconsultations through the
[dispatch].exec_templateevery dispatchable harness alreadydeclares. No new field, no new capability, no re-lint.
The structured route stays unusable for the reason #257 documented: a dispatch runs
headless with stdin detached, so a harness-native question tool has no channel to be
answered on. What is gone is the shape-table contract — an agent no longer has to
phrase its question a particular way to be answered, which was the source of the
formatting confound above.
Schema changes
evals.schema.json—responder.typeenumheuristic→llm, description rewritten(it is a model dispatch, no longer "deterministic and free").
typestays required:the responder decides what the agent hears, so it is said out loud.
conversation.schema.json—originlosesanswers/rule/chosenand gains anoptional
rationale; new optionalresponder_outcome(ending,cause,rationale).run-record.schema.json— the same two edits to its verbatim duplicates. Theduplication is a known drift hazard, not deduplicated here;
a_responder_record_satisfies_both_schemas_and_roundtripsis what catches it.ResponderEndingis deliberately not the responder module's parsedVerdict: adelivered answer is recorded on the turn it became, so only the two endings can reach
ResponderOutcome— unrepresentable rather than merely unwritten.A bug the review caught
The first draft read a stale
verdict.jsonon--overwrite, answering the rerun'sagent with a reply written about a previous conversation — precisely the silent
contamination this PR exists to prevent. Reproduced by
a_rerun_does_not_reuse_the_previous_dispatch_verdict(it completed happily with thestale answer);
consultnow clears the file before dispatching, which also covers aconsultation killed part way through writing one.
Structure
responder.rsis replaced wholesale: 422 lines of Markdown parser out, a promptbuilder, a ~40-line dispatch, and two pure validators in.
turn_plan.rs'snext_from_verdictowns the verdict→NextTurnmapping and themax_turnsordering;the scripted and one-shot arms are untouched.
run_task's four dispatch-wide argumentsbecame a
DispatchSettingsstruct rather than an eighth parameter.tests/run/conversation/responder.rscrossed 500 lines (331 → 589) and was split at areal seam:
responder.rs(drives a conversation, 389) andresponder_guards.rs(whatit is shown and what is never delivered, 216). No other file crossed the line —
conversation.rs,types.rs, andaggregate.rswere all already over 500.dispatch.rsremains 1061 lines and was already over 1000 before #257; it gets structfields here, not a refactor.
CLI and documentation
eval-magic docs conversationsrewritten: the shape table is gone, replaced bywhat the responder is shown, the three verdicts, the validation table, what each
ending means for the data, and where the artifacts live.
run --helpgains--responder-modeland its turn-cost paragraph now accounts forthe extra dispatch per round — the only place that cost is stated.
dispatch --help,docs/progressive-enhancements.md,docs/guides/byoh.md,harnesses/template.toml,profiles/shared/runbook.mdand the fourrunbook.golden.md(one paragraph each).--agent-envclaimed it "does not affect judge agents" whilejudges.rshas always passed it. Wording corrected; behaviour unchanged.README.mdanddocs/developer_overview.mdneeded no edit — both already say "aresponder policy that derives them", which is still true.
Verification
Each new test was written first and confirmed failing for the right reason. The
BASELINE.mdassertions were proven to bite by deleting the row and watching threetests fail.
Parser and validation (
responder.rs, 14) — the prompt carries the exchange andnames where to write, prior replies are carried in, each verdict parses, a fenced
verdict is unwrapped, an unknown verdict and non-JSON are
malformed_verdict, and eachrejection rule fires with its own cause.
Mapping (
turn_plan.rs, 5) — an answer below the bound is delivered with its origin,an answer at the bound stops without delivering and records no verdict,
doneandcannot_answerrecord their endings, and a failed consultation carries its own cause.End to end (
responder.rs+responder_guards.rs, 12) — a free-form question isanswered and the run completes; the opening prompt carries no origin; the prompt
withholds the grading criteria; declined, nonzero-exit, no-verdict, and code-carrying
replies each stop with the right cause; a rerun does not reuse a stale verdict;
max_turnsstill stops; a harness without native resume is still rejected; theresponder model reaches
conditions.jsonanddispatch.json; Mode B parity.Aggregation (
tests/cli/aggregate.rs, 2) — truncated runs produce a per-conditionvalidity warning naming the causes, and a campaign the responder completed produces
none.
Round-trip (
artifact_tests.rs, 3) — a responder record satisfies both schemas, acompletion records its rationale and no cause, and every stop cause prints the name it
serializes as.
🤖 Generated with Claude Code
https://claude.ai/code/session_018PmT77zVcqVKTNmYXKn5ui