feat(run): derive conversation turns from a heuristic responder - #284
Merged
Conversation
An eval could only drive a multi-turn conversation by scripting it: an
ordered `turns` array whose author had to predict what the agent would
ask, and in what order. A realistic task against a real codebase is not
predictable that way.
Add a per-eval `responder` policy as the alternative. After each round it
reads the agent's final message as Markdown and answers one shape of
question — a list of options introduced by a question line. A marked
recommendation wins; failing that, a single-choice list takes its first
option and a checkbox list takes nothing. No question means the agent is
done, so the conversation ends instead of burning its remaining turns.
The heuristic never guesses. A question it cannot classify stops the run
with `responder_cannot_answer` rather than inventing a reply, which is
the branch the LLM answering agent will take over. Reaching `max_turns`
stops with `max_turns_reached`. Both are recorded results that still
ingest, but they end with the task unfinished, so `dispatch` warns about
each one by name.
Every synthesized turn carries an `origin` naming the rule that produced
it and the options it read, so a judge sees exactly what the agent was
told and a human can audit whether the responder distorted the run. The
eval's own opening prompt carries none; that absence is what tells an
authored turn from a derived one.
No harness-specific code and no new descriptor field. The responder reads
`TranscriptSummary::final_text`, which every harness's parser already
normalizes, and replies through the existing `{prompt_arg}` slot. The
structured alternative is not merely avoidable but unusable: a dispatch
runs headless with stdin detached, so a harness-native question tool has
no channel to be answered on. What is borrowed from one harness is the
convention — `(Recommended)` and checkbox lists — and the recognized
shapes are documented as a harness-neutral contract so another harness's
agent that offers options the same way is answered identically.
Also fixes a pre-existing gap: `run-record.schema.json` never learned the
`timed_out` status `conversation.schema.json` gained alongside per-task
timeouts, so `ingest` failed outright on any timed-out task. Responder
runs against a real codebase are exactly the ones that hit a deadline.
Closes #257. Part of #244.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Caup9LtqB1s8gKx9RTkY2c
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 #257. Part of #244.
What changes for a user
An eval could only drive a multi-turn conversation by scripting it — an ordered
turnsarray whose author had to predict what the agent would ask, and in whatorder. A realistic task against a real codebase is not predictable that way.
An eval can now declare a
responderinstead, and the runner derives eachfollow-up from what the agent just said:
{ "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": "heuristic", "max_turns": 8 } }turnsandresponderare alternatives, not layers; declaring both is avalidation error. Declaring neither still means one-shot dispatch. Retiring
turnsis not this PR.The heuristic
Per #244's decision 3, heuristics only — the LLM answering agent is #258, kept
separate so this loop is proven before a second model enters the attribution
picture.
It reads the round's final assistant message as Markdown. A bullet or
numbered list counts as a question only when the line directly above it ends
with
?:Ending, not merely containing. That distinction is load-bearing and was found by
a test: a closing summary is also mostly a bulleted list, and
would otherwise have been "answered" — derailing a finished task by replying
"Wrapped the client" to nobody's question.
Given a list, the choice is mechanical:
-,*,1.)recommended_optionfirst_option- [ ])recommended_optionno_selectionNone of these.Plain lists ask for exactly one; checkboxes ask for zero or more. That syntax is
the only signal used to tell them apart. An option is recommended when it carries
a standalone
recommendedin parens, brackets, or bold — or is a pre-checked- [x]box. A message asking several questions is answered in one numbered turn.How a conversation ends
completedstopped/responder_cannot_answerstopped/max_turns_reachedBoth responder stops are recorded results —
dispatchexits 0 andingeststillrecords the run — but both end with the task unfinished, so each is warned
about by name rather than passing silently into the data:
The design is deliberately conservative in one direction: a stray question mark
in an otherwise-finished message stops rather than claiming completion. That
costs a dispatch; the alternative — recording a run as complete while the agent
was still waiting — would cost the result's credibility. Pinned by a test.
Provenance
Every synthesized turn names the rule that produced it and the options it read.
The opening prompt carries no
originat all — that absence is what tells anauthored turn from a derived one.
{ "type": "user_message", "ordinal": 2, "round": 2, "text": "An in-process LRU", "origin": { "responder": "heuristic", "answers": [{ "question": "Before I start — which cache should I use?", "options": ["An in-process LRU (Recommended)", "Redis", "Cache at the CDN"], "rule": "recommended_option", "chosen": ["An in-process LRU"] }] } }run.jsoncarries the wholeConversationRecord, so this reaches the report withno extra plumbing; the policy that drove the run is recorded on the task in
dispatch.json. (Recording a responder model inconditions.jsonis #258'scriterion — there is no model here.)
Cross-harness: no harness-specific code, and none possible
No new descriptor field and no named capability. The responder reads
TranscriptSummary::final_text, which every harness's parser already normalizes,and replies through the existing
{prompt_arg}slot inresume_exec_template.Any harness that can already run scripted
turnsruns a responder eval unchanged;cline can run neither, for the reason its descriptor already documents.
The structured route — Claude Code's
AskUserQuestion, whose argsTranscriptEvent::ToolInvocationalready captures in full — is not merelyavoidable here but unusable: a dispatch runs headless with stdin detached, so
a tool asking the user has no channel to be answered on, and the runner can only
send free text as the next user turn. Text is the only mechanism that fits the
architecture, and it happens to be the portable one.
What is borrowed from one harness is the convention:
(Recommended)andcheckbox lists are how Claude Code's own question UI renders choices. So the
recognized shapes are documented as a harness-neutral contract in
eval-magic docs conversations, not as "what Claude does" — an agent that offersoptions that way is answered identically whatever harness runs it, and one that
phrases them differently stops with
responder_cannot_answer: a documented gap inthe shape table, not a missing descriptor field. Widening the table is a runner
change that benefits every harness at once.
docs/progressive-enhancements.mdrecords this under "Native conversationresume", so a harness maintainer reading that section learns they need nothing.
Deliberately not done: telling the agent in its dispatch prompt to mark
recommendations. It would fire the heuristic far more often, and would inject the
very asking-behavior some skills under test are meant to be measured on. #258 is
the real fix.
Adjacent fix:
ingestfailed on every timed-out taskrun-record.schema.json's embedded conversation definition never learned thetimed_outstatus,timed_out_in_round, or the relaxedevents.minItemsthatconversation.schema.jsongained with per-task timeouts (#256).record_runsclones the whole
ConversationRecordinto the run record and validates it there,so a single hung task failed the whole ingest. Proven by reverting the fix under
the new test:
Pre-existing, but responder runs against a real codebase are exactly the ones that
hit a deadline. The run-record copy now mirrors the conversation schema's four
conditional rules.
Schema changes
evals.schema.json— newdefinitions/responder(typerequired,heuristiconly; optional
max_turns, minimum 1, default 8), aneval.responderproperty,and an
allOfrejectingresponderalongsideturns. The clash is alsochecked before the schema, because a bare
notreports the whole eval asdisallowed and never names the two fields — the same reason the codebase rules
are pre-checked.
conversation.schema.json—stop_reasongainsresponder_cannot_answerandmax_turns_reached;userMessagegains the optionaloriginobject.run-record.schema.json— the same two edits to its embedded copy, plus thetimed_outparity fix above.Structure
run_task's scriptedforloop becomes oneloopthat asks aTurnPlanfor thenext turn, so one-shot, scripted, and responder tasks share a single delivery path
and a single round counter — keeping the
delivered_followups + 1 == roundsidentity three ingest sites reconstruct rounds from. The scripted arm keeps
unmet_gateand its joined-assistant-text input exactly as they were.The driver was heading past 500 code lines, so the new policy is carved out rather
than piled on:
conversation.rs(518 code) runs and records rounds,conversation/turn_plan.rs(141) decides what comes next, andconversation/responder.rs(214) is the parser.dispatch.rsremains 1054 linesand was already over 1000 before this PR — the 11 lines added here are struct
fields that cannot be carved from their struct; flagging it as needing its own effort.
CLI and documentation
eval-magic docs conversations(
docs/guides/conversations.md) — the two ways to drive a conversation, therecognized question shapes as a contract, what each ending means for the data,
and the cross-harness position.
dispatchandinithelp rewritten around multi-turn rather than "scripted".docs/progressive-enhancements.md,docs/developer_overview.md,docs/guides/byoh.md,harnesses/template.toml,profiles/shared/runbook.md,README.
Goldens: only the four
runbook.golden.mdfiles moved — one intended paragrapheach, explaining that a responder stop is recorded but ended mid-task. Nothing
else re-blessed.
Verification
25 new tests, each written first and confirmed failing for the right reason.
Parser (
conversation/responder.rs) —a_recommended_option_is_chosen,a_plain_list_with_no_recommendation_takes_the_first_option,a_checkbox_list_with_no_recommendation_selects_nothing,a_checkbox_list_selects_every_recommended_option,a_pre_checked_box_counts_as_a_recommendation,a_separator_left_by_a_trailing_marker_is_cleaned_up,two_option_groups_are_answered_in_order,a_closing_summary_with_a_bulleted_list_is_not_a_question,a_stray_question_mark_stops_rather_than_claiming_completion,a_free_form_question_is_unanswerable,a_message_with_no_question_reads_as_done.Driver (
tests/run/conversation/responder.rs) —a_responder_eval_answers_a_recommended_option_and_completes,the_opening_prompt_carries_no_responder_origin,a_responder_run_that_reaches_max_turns_is_recorded_not_failed,a_question_the_responder_cannot_classify_stops_the_run,a_responder_eval_is_rejected_on_a_harness_without_native_resume,revision_mode_runs_a_responder_eval(Mode B parity).Config + artifacts — four validation cases, plus
a_responder_record_satisfies_both_schemas_and_roundtripsanda_timed_out_conversation_satisfies_the_run_record_schemaproving the Rust typesand both schemas agree, and
records_a_run_whose_conversation_timed_out_in_a_later_round/a_responder_task_without_its_completion_artifact_is_skipped_as_incompleteat theingest boundary.
Also driven end to end by hand against a stub harness —
run → dispatch → ingest— for the answering path (origin
recommended_optionreachingrun.json) and thestop path (exit 0, the warning above,
responder_cannot_answerinrun.json).Reviewer's attention
responder_cannot_answeris the seam for #258. #258 triggers on exactly thisbranch and degrades back to it on failure, so the name is the consequence rather
than the shape — it stays accurate when an LLM responder also cannot answer.
max_turnscounts synthesized follow-ups, not rounds. The opening prompt isnot one, which is what keeps it identical to
delivered_followups.Classification happens before the bound. An agent that stops asking on its last
permitted turn
completeds; it did not run out of budget.TaskOutcome::StoppedcarriesOption<ConversationStopReason>. The schema gatein
write_conversationguarantees a reason exists, but filling an absent one withan arbitrary variant would mislabel the outcome if that invariant ever moved.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Caup9LtqB1s8gKx9RTkY2c