Repository navigation
feat(simulation): add scripted user turns (simulation.turns) - #246
Conversation
Add `simulation.turns`: a fixed, ordered list of authored user turns that the
dialog loop sends verbatim to the agent within ONE session, each exchange
recorded as its own TurnRecord. No LLM plays the user.
- `turns` is non-empty, replace-merged, and carries the first prompt too, so
`initial_prompt` / `initial_prompt_file` are rejected beside it. `persona`
and `goal` become optional (required unless `turns` is set); the simulator
and criteria-timing fields must keep their defaults, compared by value so a
recorded model_dump() still validates. `max_turns` must be >= len(turns).
- `ScriptedUser` replays the turns through the surface the loop already uses
on `UserSimulator`: no model call, no `claude` CLI, credentials or route,
zero simulator tokens, `simulator_model: null`. `evaluate_stop` ends the
dialog with the new `script_exhausted` reason after the last turn.
- A scripted dialog never reads criteria to continue, so the loop checks them
only on a graded run: `coder-eval execute` accepts it (NOT_GRADED) and
`evaluate` grades it later. LLM-simulated dialogs are still refused.
- A turn that fails after its retries, exhausts `run_limits.max_turns` or
breaches a budget ends the script; later turns are not sent.
- New task field `working_directory_prefix` (default true). `false` sends each
prompt, single-shot or dialog turn, byte for byte.
- `network: llm_only` no longer allowlists a simulator route for a script.
Docs: Dialog Mode ("Scripted dialogs"), Task Definition Guide, User Guide,
Docker Isolation, orchestration notes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL's Python extractor does not treat a PEP 695 `type` statement as a definition, so it reported `DialogUser` in `__all__` as undefined (code scanning alert 203). A plain assignment is the same alias to pyright and is not flagged; `typing.TypeAlias` would trip ruff's UP040. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
osv-scanner fails the Quality Gate on pyasn1 0.6.4 (GHSA-2qm8-x737-gmw5, GHSA-9m75-vc3g-4x39, GHSA-fm9c-5rcf-2q5p, GHSA-mm57-4v3p-rchx), on main as well. 0.6.5 fixes all four. Lockfile only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI status on ffa1713: everything passes except Delegate Live E2E (ROPC), which fails before any test runs at "ROPC login — mint a fresh Delegate access token" with |
|
Claude finished @cosminacho's task in 1m 23s —— View job Code Review in Progress
|
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:246 (22 files) axis:1,2,3,4,5,6,7,8
Scope: pr:246 (22 files) axis:1,2,3,4,5,6,7,8 · branch feat/scripted-dialog-turns (pr-246) · ffa1713 · 2026-11-05T14:21Z · workflow variant
Change class: complex — adds a new schema field family (simulation.turns, working_directory_prefix), a new stop reason, and changes orchestrator dialog control flow and execute/grading gating
PR 246 adds scripted user turns with clean security, typing and error handling, but it puts every scripted dialog on paths that can give a wrong result: a FinalStatus can be wrong, dataset rows can get unrendered prompts, variant prompt changes can be silently ignored and recorded configs can fail to reload. Fix the two high-severity Evaluation Harness defects and the prompt-override gap before merge.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 8.8 / 10 | 0 | 0 | 2 | 2 | PR raises complexity of the hottest dialog driver: _simulation_dialog_loop F(41) to F(44), _evaluation_loop D(23) to D(24), partly through an optional user parameter with a fallback built inside the loop |
| 2. Type Safety | 9.9 / 10 | 0 | 0 | 0 | 1 | persona/goal loosened to `str |
| 3. Test Health | 9.2 / 10 | 0 | 0 | 1 | 3 | The ungraded-dialog backstop that this PR narrowed has no test on either side |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.2 / 10 | 0 | 0 | 1 | 3 | The 'one source of truth for the first prompt' invariant is enforced only when TaskDefinition is built. The variant prompt layer bypasses it, so a variant initial_prompt is silently ignored and the recorded config can no longer be reloaded |
| 6. Error Handling & Resilience | 9.9 / 10 | 0 | 0 | 0 | 1 | ScriptedUser's documented IndexError (a first-party cursor invariant) is swallowed by the simulator's blanket except and logged as a simulator failure |
| 7. API Surface & Maintainability | 9 / 10 | 0 | 0 | 2 | 0 | simulation.max_turns is a redundant, must-set second source of truth for scripted dialog length |
| 8. Evaluation Harness Quality | 7.4 / 10 | 0 | 2 | 1 | 1 | Scripted dialog loses agent max_turns_exhausted on final turn; row classified FAILURE not MAX_TURNS_EXHAUSTED |
Overall Score: 9.2 / 10 · Weakest Axis: Evaluation Harness Quality at 7.4 / 10
Totals: 🔴 0 · 🟠 2 · 🟡 7 · 🔵 11 across 8 axes.
Blockers
- [Axis 8] Scripted dialog loses agent max_turns_exhausted on final turn; row classified FAILURE not MAX_TURNS_EXHAUSTED (
src/coder_eval/orchestrator.py:2608) — In_simulation_dialog_loop, theevaluate_stopbreak (L2602-2611:stop_decision = evaluate_stop(...)/if stop_decision.stop: ... stop_reason = stop_decision.reason/break) runs BEFORE the fact recording at L2618-2619 (if turn_record.max_turns_exhausted:/self.result.max_turns_exhausted = True). The new precedence entry at termination.py:72-73 (if config.turns is not None and turns_completed >= len(config.turns): return StopDecision(stop=True, reason=DialogStopReason.SCRIPT_EXHAUSTED)) makes this break the NORMAL exit of every scripted dialog. As a result, when the agent exhausts its inner max_turns on the final scripted turn,result.max_turns_exhaustedstays False and_terminal_status(L475:if self.result.max_turns_exhausted: return FinalStatus.MAX_TURNS_EXHAUSTED) falls through to FAILURE. I reproduced this with the PR's own_SessionAgentfixture and a failingrun_command: falsecriterion.exhaust_on_call=len(TURNS)givesFAILURE mte=False stop=script_exhausted.exhaust_on_call=1givesMAX_TURNS_EXHAUSTED mte=True stop=agent_max_turns. The same agent behaviour therefore lands in different FinalStatus buckets depending only on which turn it happened on. A one-turn script also diverges from the equivalent single-shot task, which records the fact before grading (L2210-2211). Underexecute, the fact is never persisted, so a laterevaluatecannot recover it. This is the scoring-correctness class: a status classifier that puts a value in the wrong bucket. The MAX_TURNS final turn of an LLM dialog had the same ordering before this PR, but the PR puts every scripted dialog on this path. Fix: recordself.result.max_turns_exhausted = Trueright afterself.result.iterations.append(turn_record)(a fact about the run, as on the single-shot path), and keep only the AGENT_MAX_TURNS break below the stop decision. Add a test that exhausts max_turns on the LAST scripted turn (test_scripted_dialog.py:316 covers onlyexhaust_on_call=1). - [Axis 8] Dataset ${row.*} placeholders not substituted into simulation.turns (
src/coder_eval/models/tasks.py:740) —check_scripted_turns_replace_the_prompt(L740-753) forbidsinitial_promptbesidesimulation.turns("simulation.turns supplies every user turn, the first included: remove 'initial_prompt'"). Butexpand_dataset(orchestration/task_loader.py) substitutes rows only indata["initial_prompt"]anddata["success_criteria"]. It never touchessimulation.turns. So for a scripted task with adataset:block, the only path that injects row data into the prompt is closed, and any${row.x}inturnsreaches the agent verbatim. I reproduced this: a dataset task withturns: ["Question: ${row.q}", "Thanks"]expands tods-scripted/r1 ['Question: ${row.q}', 'Thanks'] r1.txtandds-scripted/r2 ['Question: ${row.q}', 'Thanks'] r2.txt. The criteria ARE row-substituted, so N rows grade row-specific expectations against one identical, unrendered prompt, and nothing fails or warns. Fix: substitute${row.*}in everysimulation.turnsentry inexpand_dataset(and document it in DATASETS.md § what is substituted), or rejectdataset:+simulation.turnsat validation until that is supported. Add a test for each choice. Static-check candidate: a load-time check that no${row.survives in any string leaf after expansion.
Non-blocking, but please consider before merge
- [Axis 1] PR raises complexity of the hottest dialog driver: _simulation_dialog_loop F(41) to F(44), _evaluation_loop D(23) to D(24), partly through an optional
userparameter with a fallback built inside the loop (src/coder_eval/orchestrator.py:2440) — I ran radon cc -s on the PR worktree and on origin/main.Orchestrator._simulation_dialog_loopgoes from F (41) to F (44) andOrchestrator._evaluation_loopgoes from D (23) to D (24), in the hot orchestrator module. Three new branches cause the rise. The first is the optional injected user, which is resolved inside the loop (lines 2471-2481):simulator = (\n user\n if user is not None\n else UserSimulator(config=sim_config, ...)). The other two are theself.grade andguards at 2596 (if self.grade and not criteria_checked_this_turn:) and 2656 (end_of_dialog_needed = self.grade and (). The dispatch at 2168-2175 already branches onif simulation.turns is None:, so it knows which user it needs. Build theDialogUserat the dispatch, for exampleuser = ScriptedUser(turns) if scripted else UserSimulator(...), and makeusera required parameter of_simulation_dialog_loop. This removes the None-default parameter, the fallback construction and one decision point. It also stops the 'scripted or LLM' choice from being split between two methods. Consider folding the twoself.gradeguards into the existing_run_dialog_criteria_checkseam, so the loop body does not grow again. - [Axis 1] Scripted vs simulated user selected via flat model plus hand-maintained _FIXED_WHEN_SCRIPTED list (fails open), not a discriminated user source (
src/coder_eval/models/tasks.py:55) —_FIXED_WHEN_SCRIPTED = ("persona", "goal", "constraints", "model", "stop_token", "stop_on_criteria_pass", "check_criteria")(lines 55-63) is a magic-string list._validate_user_sourceloops over it at lines 301-309:if getattr(self, name) != fields[name].get_default(call_default_factory=True). Every future simulator-only knob added to SimulationConfig must also be added to this tuple by hand. If someone forgets,turnssilently accepts a knob that does nothing, which is exactly the problem the validator is meant to prevent. Nothing (lint or test) ties the tuple to the model's fields. The same design also weakenspersona/goaltostr | Nonefor every simulated task, and adds anis_scriptedproperty and a by-value default comparison to work aroundmodel_fields_set. Model the user source as a discriminated pair of sub-models instead, for example a scripted spec{turns}and a simulated spec{persona, goal, constraints, model, stop_token, ...}, each withextra="forbid". Pydantic then rejects contradictory keys structurally,persona/goalstay required on the simulated branch, and the tuple, the comparison loop and the None-loosening can be deleted. The CLAUDE.md pattern 'Discriminated unions for criteria types and template sources' is the precedent. - [Axis 3] The ungraded-dialog backstop that this PR narrowed has no test on either side (
src/coder_eval/orchestrator.py:2310) — The PR addsif self.grade and not criteria_checked_this_turn:(line 2596) andend_of_dialog_needed = self.grade and ((line 2656). Before the PR, any ungraded dialog that reached a criteria check hit the backstop at 2310-2315 (if not self.grade: raise ValueError("Grading is disabled but the simulation dialog path requires criteria results ...). After the PR, only the per-turn (every_turn/both) check can reach it, and the comment now says it is "reachable ungraded only past the CLI refusal". Line 2311 is in a PR-modified hunk and is uncovered (coverage reports 2311 missing). The only ungraded tests (test_a_script_runs_in_full_ungraded_and_finalizes_not_graded,test_a_budget_breach_ends_an_ungraded_script_with_its_own_statusin tests/test_scripted_dialog.py) use scripted dialogs, where the validator forcescheck_criteria='end_of_dialog'. No test callsOrchestrator(grade=False)on an LLM-simulated dialog. Also, an LLM-simulated end_of_dialog dialog under grade=False used to raise and now finishes NOT_GRADED, and no test asserts that behavior change. Add two tests, with UserSimulator stubbed: (a) an LLM-simulated task withcheck_criteria='every_turn'and grade=False raises the ValueError, which trips the gate; (b) an LLM-simulated task with end_of_dialog and grade=False finalizes NOT_GRADED with emptysuccess_criteria_results. These tests keep a later change from removing the backstop because it "looks unreachable". - [Axis 5] The 'one source of truth for the first prompt' invariant is enforced only when TaskDefinition is built. The variant prompt layer bypasses it, so a variant initial_prompt is silently ignored and the recorded config can no longer be reloaded (
src/coder_eval/models/tasks.py:740) — The PR moves the first prompt into a second place (simulation.turns[0]). It guards this only with a TaskDefinition after-validator:def check_scripted_turns_replace_the_prompt(self) -> Self:...and simulation.is_scripted and (self.initial_prompt is not None or self.initial_prompt_file is not None). The A/B prompt layer (orchestration/experiment.py::_apply_prompt_overrides) changestask.initial_promptin place after that validation, and nothing validates again (TaskDefinition has no validate_assignment, andapply_overridesvalidates only the agent/run_limits/sandbox roots). I verified this with a probe. A variantinitial_prompt: "VARIANT PROMPT"on a scripted task resolves without error toinitial_prompt='VARIANT PROMPT'withturnsunchanged. The orchestrator then ignores it (orchestrator.py:2173-2174:return await self._simulation_dialog_loop(simulation.turns[0], sandbox_dir, user=ScriptedUser(simulation.turns))), so the A/B lever does nothing and no message tells the user. Also,TaskDefinition.model_validate(task.model_dump())on that resolved task now raisessimulation.turns supplies every user turn ... remove 'initial_prompt', so the recorded config of the run does not reload forevaluate <run_dir>or resume. Experiment-leveldefaults.prompt_mutationscause the scripted task to fail resolution with the misleading messageinitial_prompt must be resolved before applying mutations. The notes added by the PR claim 'the task carries one source of truth for its first prompt', but the merge pipeline breaks that claim. Fix: teach the prompt-override layer about scripted tasks. Either reject a variantinitial_prompt/prompt_mutationson a scripted task with a clear message, or apply mutations tosimulation.turnson purpose. Also validate the whole TaskDefinition again after_apply_prompt_overrides, so a TaskDefinition invariant cannot be broken by a later layer. Add a resolver test for a variant prompt override on a scripted task. - [Axis 7] simulation.max_turns is a redundant, must-set second source of truth for scripted dialog length (
src/coder_eval/models/tasks.py:310) — The validator forcesmax_turns >= len(turns):if len(self.turns) > self.max_turns: raise ValueError(... "raise max_turns to at least {len(self.turns)}.")(tasks.py:310-314). Butevaluate_stopchecksif config.turns is not None and turns_completed >= len(config.turns): return ... SCRIPT_EXHAUSTED(termination.py:72-73) BEFOREturns_completed >= config.max_turns. Because len(turns) <= max_turns is guaranteed, the script always ends first, so MAX_TURNS can never fire for a scripted dialog. The result is a knob with no effect that a user must still edit: any script longer than the default 8 turns is rejected until the user raisesmax_turns. Both docs examples carry the same note (max_turns: 3 # Must be at least len(turns).DIALOG_MODE.md:106;max_turns: 8 # Must be at least len(turns).TASK_DEFINITION_GUIDE.md:1809). The progress log also shows a wrong denominator:logger.info("Simulation turn %s/%s", turns_completed, sim_config.max_turns)(orchestrator.py:2544) prints e.g.2/8for a 3-turn script. Fix: whenturnsis set, derive the cap fromlen(turns). Either addmax_turnsto_FIXED_WHEN_SCRIPTEDand ignore it, or make the effective caplen(turns)and drop the length check. Then remove the 'Must be at least len(turns)' requirement from the field description and both docs. - [Axis 7] Scripted tasks cannot share an experiment whose defaults set a simulator knob or prompt_mutations; such tasks are quietly moved to skipped (
src/coder_eval/models/tasks.py:55) —_FIXED_WHEN_SCRIPTED = ("persona", "goal", "constraints", "model", "stop_token", "stop_on_criteria_pass", "check_criteria")is checked in_validate_user_source(tasks.py:302-308) against the MERGEDSimulationConfigthat_resolve_simulationbuilds from all four layers. The Dialog Mode docs recommend pinningsimulation.modelfor A/B runs. If an experiment does that indefaults.simulation(or setscheck_criteria: every_turnfor its simulated tasks), every scripted task in the same suite fails. I reproduced it:defaults: {simulation: {check_criteria: every_turn}}givesValidationError: simulation.turns scripts the user, so these must keep their defaults: ['check_criteria'].resolve_all_taskscatches this ValueError and moves the task toskipped, so a mixed suite gets smaller with only a log warning. The same happens with an experiment-levelprompt_mutations: a scripted task (initial_prompt None) raises the misleadinginitial_prompt must be resolved before applying mutationsand is also skipped. Fix: run the 'must keep defaults' rule against the task's own layer only (the authoredsimulationblock), not the inherited experiment layers. Or let a scripted config ignore inherited simulator-only knobs. Also give the prompt_mutations path a clear error, or apply the mutations toturns[0]. - [Axis 8] The scripted-user validator compares simulator fields to the current code defaults by value, so a later default change makes old recorded scripted runs fail to load (
src/coder_eval/models/tasks.py:306) —_validate_user_sourcerejects a scripted config whengetattr(self, name) != fields[name].get_default(call_default_factory=True)(L306) for every name in_FIXED_WHEN_SCRIPTED. That set includes "model" (L59), whose default isDEFAULT_SIMULATOR_MODEL = DEFAULT_JUDGE_MODEL = "anthropic.claude-sonnet-4-6", and "stop_token". The orchestrator recordsself.recorded_task.model_dump(warnings=False)(orchestrator.py:1075), which bakes today's default model into every scripted run'stask_config.resolved. When a release changes DEFAULT_JUDGE_MODEL,regrade.py:102(TaskDefinition.model_validate(record.resolved)) raises for every scripted run recorded earlier.evaluate <run_dir>/run --resumethen takes_fall_back_to_source, which warns "Variant overrides, -D flags and dataset expansion from the original run are NOT reapplied, so this grade may not match what ran". That can grade a config other than the one that ran. The same check also breaks experiment resolution in a mixed suite wheredefaults.simulationor a variant pinssimulation.model, which is the A/B practice themodelfield description recommends ("hold it fixed to keep the dialog partner constant across variants"). Each scripted task then fails with "these must keep their defaults: ['model']". Fix: make the simulator-only fieldsNone-default (resolved to the constant at use time) and reject only non-None values whenturnsis set. Alternatively, ignore (and drop)model/stop_tokenfor a scripted user instead of failing on them.
Nits
- [Axis 1] Dialog loop docstring and local names still say 'LLM user simulator' after the loop is generalized to any DialogUser (
src/coder_eval/orchestrator.py:2443) — The docstring at line 2443 still begins"""Run the task as a multi-turn dialog driven by an LLM user simulator., and the local variable that can now hold aScriptedUseris still calledsimulator(line 2471simulator = (), as are thesimulator: DialogUserparameters of_solicit_user_message(2354) and_acquire_opener(2385). Rename them touser/ 'dialog user' and change the summary line to 'driven by a DialogUser (LLM simulator or scripted turns)'. Readers can then see that the scripted path goes through these helpers. - [Axis 1] Test helper flag
verbatimis an inverted alias forworking_directory_prefix(tests/test_scripted_dialog.py:90) —def _task(turns: list[str] = TURNS, *, verbatim: bool = True, **overrides: Any)mapsverbatim=Truetokwargs["working_directory_prefix"] = False(line 100), and the docstring has to explain thatverbatim=Falseleaves the prefix at its default. Readers must translate the negation at every call site (_task(verbatim=False)). Name the parameter after the model field, for exampleworking_directory_prefix: bool = False, and pass it straight through. - [Axis 2] persona/goal loosened to
str | None; the simulated-user invariant is now validator-only and UserSimulator interpolates the optional values unnarrowed (src/coder_eval/models/tasks.py:199) — The PR changespersona: str = Field(topersona: str | None = Field(default=None, ...)(line 199), and does the same forgoal: str | None(line 206). Only_validate_user_source("if self.persona is None or self.goal is None: raise ...") enforces that a non-scripted config has both. The consumer_extract_system_promptin simulation/user_simulator.py:95/98 still interpolates{config.persona}/{config.goal}directly. Pyright acceptsstr | Nonein an f-string, so if a scripted config ever reachedUserSimulator, the type checker would stay silent and the simulator prompt would contain the literal text "None". Fix: narrow the values once at theUserSimulatorboundary (if config.persona is None or config.goal is None: raise ValueError(...)in__init__, then passstrdown). Alternatively, give the simulated-user path a typed accessor that returnstuple[str, str], so the invariant is visible to pyright and does not depend only on a model validator. Today the orchestrator only buildsUserSimulatorwhenturns is None, so the current code is not wrong. - [Axis 3] Duplicate evaluate_stop test; the case where a script is shorter than max_turns is never exercised (
tests/test_simulation_termination.py:117) —test_script_exhausted_is_reported_instead_of_max_turns(lines 117-120) makes exactly the same call astest_script_exhausted_after_the_last_turn(lines 112-115):evaluate_stop(config=_scripted_cfg(), turns_completed=3, total_tokens_used=0, criteria_all_passed=False), and it asserts a subset of what the earlier test asserts. Both use_scripted_cfg, which pins"max_turns": 3next to three turns (line 100). The end-to-end fixture in tests/test_scripted_dialog.py:102 also pinsmax_turns=len(turns). So no test at any level runs the shape the validator allows and the docs describe ("max_turns must be at least len(turns)"): a script shorter than max_turns, including the default max_turns of 8. In that shape SCRIPT_EXHAUSTED is the only rule that stops the dialog. If it fails to fire,ScriptedUser.next_user_messageraises IndexError,_solicit_user_message'sexcept Exceptionswallows it, and the run is silently mis-bucketed asstop_reason='error'withsimulator_failures=1. Replace the duplicate with a case like_scripted_cfg(max_turns=8)at turns_completed=3 (expect SCRIPT_EXHAUSTED), and add one orchestrator test that leaves max_turns at its default. - [Axis 3] prompt_mutations and initial_prompt_file on a scripted task are untested and give a misleading error (
tests/test_simulation_trials.py:128) —TestScriptedTaskUnderAVariantPromptcovers onlyExperimentVariant(variant_id="rewrite", initial_prompt="A different opener.")(line 156). ExperimentVariant and ExperimentDefaults also takeprompt_mutations, and variants takeinitial_prompt_file. In src/coder_eval/orchestration/experiment.py, a scripted task (initial_prompt None) with any default or variant mutation reachesraise ValueError(f"initial_prompt must be resolved before applying mutations (task '{task.task_id}')"). This internal-sounding message does not mentionsimulation.turns, and an experiment-widedefaults.prompt_mutationsbreaks every scripted task in the run with it. Add parametrized cases forprompt_mutations(variant and defaults) andinitial_prompt_file, and assert an error that namessimulation.turns(or assert a deliberate decision to apply mutations to the turns). - [Axis 3] New layered fields have no experiment-defaults or -D override coverage (
tests/test_simulation_trials.py:85) — Review criterion 10 requires new layered fields to have coverage across the merge layers plus a matching-Dpath.simulation.turns(MergeField strategy="replace") is tested only at the variant layer (test_variant_turns_replace_the_task_script_wholesale, line 85).working_directory_prefix(new TaskDefinition field) is tested only by direct construction (tests/test_simulation_config.py:262-266). There is no case for experimentdefaults.simulation.turnsor for-D simulation.turns=[...]/-D working_directory_prefix=false. There is also no cross-layer case where a variant addsturnsto a task that already haspersona/goal. Under the merge, persona and goal survive next to turns, and_validate_user_sourcerejects the result, so a variant cannot switch a simulated task to a scripted one. Pin that contract with a test so it is deliberate. - [Axis 5] LLM-simulated-dialog predicate duplicated in several ad hoc forms; is_scripted used inconsistently with raw turns-is-None checks (
src/coder_eval/cli/run_command.py:994) — The new mode distinction has no single owner. Each consumer writes it again:run_command.py:994if rt.task.simulation is not None and rt.task.simulation.enabled and not rt.task.simulation.is_scripted;isolation/egress.py:130if task.simulation is not None and task.simulation.enabled and not task.simulation.is_scripted:;orchestrator.py:2168if simulation.turns is None:(does not useis_scripted);models/tasks.py:744-746simulation.enabled and simulation.is_scripted. Thesimulation.enabledconsumers that were not updated have already drifted.models/tasks.py:793(if self.simulation is not None and self.simulation.enabled and self.sandbox.docker.grading_widens_egress:) still refuses a grading network with the reason 'its criteria run between dialog turns'. A scripted dialog must keepcheck_criteriaat itsend_of_dialogdefault, so its only criteria check runs after the last turn, and the refusal is unnecessary. Fix: add one or two predicates on TaskDefinition/SimulationConfig (for exampleuses_simulatorandreads_criteria_mid_dialog) and send every consumer through them. That includes the grading-network validator andharbor/packager.py:178, whose error message also says 'reads coder-eval's own criteria results mid-dialog'. - [Axis 5] DialogUser is a closed union, not a Protocol; LLM user still built inside the loop (
src/coder_eval/simulation/__init__.py:27) —DialogUser = UserSimulator | ScriptedUseris a closed union, not aProtocol(start,stop,next_user_message,model). Adding a third user source means editing this alias and every annotation that uses it. Composition is asymmetric._evaluation_loopbuildsScriptedUser, but_simulation_dialog_loopstill buildsUserSimulatoritself unlessuseris injected (simulator = (user if user is not None else UserSimulator(...))). Itsinitial_promptparameter now means both 'pinned opener' and 'turns[0]'. The script end is known in two places.termination.py:72readsif config.turns is not None and turns_completed >= len(config.turns):andscripted_user.py:37indexesself._turns[len(dialog_pairs)], which raises IndexError if the two places ever disagree. Fix: define aDialogUserProtocol, let_evaluation_loopbuild both user kinds and pass a requireduser, and let the user source report that it is exhausted (or keep the check only in evaluate_stop and document it as the only owner). - [Axis 5] SCRIPT_EXHAUSTED missing from HTML report stop-reason label map (
src/coder_eval/simulation/termination.py:16) — The PR addsSCRIPT_EXHAUSTED = "script_exhausted"and mirrors it inSimulationTelemetry.stop_reason. Butreports/html.py::_SIMULATION_STOP_REASON_LABELS(lines 1069-1076) has no entry for it, so_SIMULATION_STOP_REASON_LABELS.get(sim.stop_reason, ("neutral", sim.stop_reason))falls back to the raw value and no badge or label is chosen for it. This is the most common end state of a scripted dialog, so the result is real (run_limit_exceededhas the same gap and predates this PR). Fix: add"script_exhausted": ("neutral", "script completed"). Consider a lint/test that checks that the label map covers everyDialogStopReasonmember. - [Axis 6] ScriptedUser's documented IndexError (a first-party cursor invariant) is swallowed by the simulator's blanket except and logged as a simulator failure (
src/coder_eval/simulation/scripted_user.py:37) —text = self._turns[len(dialog_pairs)](line 37) is documented to raiseIndexErrorwhen asked past the last turn (line 21). Its only caller,_solicit_user_message, wraps it inexcept Exception: logger.exception(failure_log_msg); return _SolicitedMessage(message=None, ...)(orchestrator.py:2372-2374). That handler is meant for an unreliable LLM subprocess. With it, a broken first-party invariant degrades tostop_reason=ERROR,simulator_failures=1, and the log line "User simulator failed — ending dialog". The end-of-dialog grade then still runs, and the row can score SUCCESS even though scripted turns were skipped. The condition is unreachable today because evaluate_stop's SCRIPT_EXHAUSTED check comes first, so this is only hardening. Recommendation: make a scripted cursor overrun fail loud, for example raise a RuntimeError that the loop does not catch, or let the loop skip the solicit call for ScriptedUser. Also use a failure message that does not call a scripted user a 'simulator'. - [Axis 8] The grading-network refusal still treats a scripted dialog as one that grades between turns (
src/coder_eval/models/tasks.py:793) —check_grading_network_without_simulationrefuses wheneverif self.simulation is not None and self.simulation.enabled and self.sandbox.docker.grading_widens_egress:, with the reason "its criteria run between dialog turns". A scripted dialog is pinned tocheck_criteria="end_of_dialog"by_FIXED_WHEN_SCRIPTED, so its only criteria check runs after the last turn, the same as single-shot. The PR made egress.py (L130) and the execute refusal scripted-aware but left this guard as it was, so one concept now has inconsistent rules. Either exemptsimulation.is_scriptedhere (with a test that the grading network opens only after the last turn), or record in DOCKER_ISOLATION.md why scripted dialogs stay excluded.
What's Missing
Parallel paths:
- 🟡 The dataset fan-out was not updated.
orchestration/task_loader.py::expand_datasetsubstitutes${row.*}only intoinitial_promptandsuccess_criteria. The PR makessimulation.turnsthe only prompt source for a scripted task, so every row sends the same unrendered turns while its criteria are row-specific. Fix: substitute intoturns, or rejectdataset:+turns. (trigger: src/coder_eval/models/tasks.py) (restates: Axis 8: Dataset ${row.*} placeholders not substituted into simulation.turns) - 🟡 The A/B prompt layer was not updated.
orchestration/experiment.py::_apply_prompt_overridesdoes not know about scripted tasks. A variantinitial_promptis set but silently ignored (the orchestrator sendsturns[0]), and the recorded config then fails to reload. Anyprompt_mutationsraises the misleading 'initial_prompt must be resolved before applying mutations'. (trigger: src/coder_eval/models/tasks.py) (restates: Axis 5: The 'one source of truth for the first prompt' invariant is enforced only when TaskDefinition is built) - 🟡 The judge prompt was not updated.
evaluation/judge_context.py::DIALOG_HEADER, used by bothllm_judgeandagent_judge, still says 'in simulation mode the USER side is generated by an LLM simulator and may invent premises — treat any claim made only by the simulated user as possibly fabricated'. On a scripted dialog the user turns are the authored benchmark questions, so the grader is told to distrust ground truth. The PR lists this under 'Known limits' but does not fix it. Fix: choose the header bysimulation.is_scripted. (trigger: src/coder_eval/simulation/scripted_user.py) - 🔵 Several other consumers of
simulation.enabledwere not made scripted-aware, althoughrun_command.pyandegress.pywere. The grading-network refusal (models/tasks.py:793, 'criteria run between dialog turns') and thestop_earlyrefusal (orchestration/early_stop.py:124) still refuse a scripted dialog, although it reads criteria only after the last turn. Theharbor/packager.py:178message ('reads criteria mid-dialog') and thetask_loader.py:189-193message ('the simulator generates the opener') are wrong for scripted dialogs. (trigger: src/coder_eval/isolation/egress.py) (restates: Axis 5: LLM-simulated-dialog predicate duplicated in several ad hoc forms) - 🔵
Orchestrator._resolve_routesstill resolvessimulator_routefor a scripted task, andorchestrator.py:1755-1758recordsenvironment_info.simulator_routing/simulator_modelfor it. So a scripted run names a simulator model that never ran, whileSimulationTelemetry.simulator_modelis null andegress.pydrops the simulator route. Fix: skip recording these keys whensimulation.is_scripted. (trigger: src/coder_eval/orchestrator.py)
Tests:
- 🟠 No test exhausts the agent's
run_limits.max_turnson the LAST scripted turn.test_an_agent_that_runs_out_of_its_own_turns_ends_the_scriptcovers onlyexhaust_on_call=1, so the SCRIPT_EXHAUSTED break beforemax_turns_exhaustedis recorded is untested. That ordering puts the row in FAILURE instead of MAX_TURNS_EXHAUSTED. (trigger: tests/test_scripted_dialog.py) (restates: Axis 8: Scripted dialog loses agent max_turns_exhausted on final turn) - 🟡 The new
self.grade andguards in_simulation_dialog_loophave no test for an LLM-simulated dialog. Two cases are missing: grade=False withevery_turnhits the ValueError backstop, and grade=False withend_of_dialognow finalizes NOT_GRADED where before it raised. Only scripted dialogs run with grade=False in tests. (trigger: src/coder_eval/orchestrator.py) (restates: Axis 3: The ungraded-dialog backstop that this PR narrowed has no test on either side) - 🟡 No test uses a script shorter than
max_turns, including the default of 8. Every fixture pinsmax_turns=len(turns), so the case where only SCRIPT_EXHAUSTED can stop the dialog is never run. A regression would surface as an IndexError, which is swallowed and reported asstop_reason='error'. (trigger: tests/test_simulation_termination.py) (restates: Axis 3: Duplicate evaluate_stop test; the case where a script is shorter than max_turns is never exercised) - 🔵 The new layered fields have no merge-layer tests. Nothing covers experiment
defaults.simulation.turns,-D simulation.turns=[...]/-D working_directory_prefix=false, or a mixed suite whosedefaults.simulationsets a simulator knob (model,check_criteria). That last case currently moves every scripted task toskipped. (trigger: tests/test_simulation_trials.py) (restates: Axis 3: New layered fields have no experiment-defaults or -D override coverage) - 🔵 Before this PR a dialog row could never be NOT_GRADED. Now an
executerun can leave a scripted dialog row NOT_GRADED, and no test runsrun --resumeon it. The test should show that resume grades the dialog in place and does not re-run it. There is also no test with one scripted and one LLM-simulated task under oneexecutethat asserts the refusal names only the LLM-simulated task. (trigger: src/coder_eval/cli/run_command.py)
Display & mapping dicts:
- 🔵
reports/html.py::_SIMULATION_STOP_REASON_LABELShas no entry forscript_exhausted, the normal end of every scripted dialog, so the badge falls back to the raw value. No test ties the three hand-kept copies together:DialogStopReason, theSimulationTelemetry.stop_reasonLiteral, and this label map. (trigger: src/coder_eval/simulation/termination.py) (restates: Axis 5: SCRIPT_EXHAUSTED missing from HTML report stop-reason label map) - 🔵 Reports and logs do not show whether the user was scripted. The HTML Simulation section shows 'Simulator tokens in 0 · out 0' and labels errors 'simulator error' for a scripted dialog. The orchestrator logs 'User simulator failed — ending dialog' for a
ScriptedUser. (trigger: src/coder_eval/orchestrator.py) (restates: Axis 6: ScriptedUser's documented IndexError is swallowed by the simulator's blanket except and logged as a simulator failure)
Downstream consumers:
- 🟡 The new SCRIPT_EXHAUSTED exit runs before
result.max_turns_exhaustedis set, so_terminal_status(and every MAX_TURNS_EXHAUSTED count and pass-rate built on it) classifies a final-turn agent cap as FAILURE. The progress log (orchestrator.py:2544) also still divides bysim_config.max_turns, notlen(turns). (trigger: src/coder_eval/simulation/termination.py) (restates: Axis 8: Scripted dialog loses agent max_turns_exhausted on final turn)
Nightly pipeline:
- 🟡 The PR does not say what a routine release does to recorded scripted runs.
_validate_user_sourcecomparesmodel/stop_tokenby value with today's defaults. The nextDEFAULT_JUDGE_MODELbump will make every earlier scripted run'stask_config.resolvedfail validation, and nightlyevaluate <run_dir>/run --resumethen falls back to re-reading the source task YAML. (trigger: src/coder_eval/models/tasks.py) (restates: Axis 8: The scripted-user validator compares simulator fields to the current code defaults by value) - 🔵 The cross-repo contract changes, but the PR body and
docs/REPORT_SCHEMA.mddo not say so.task.jsonsimulation.stop_reasongainsscript_exhausted,simulator_modelcan be null, and the recordedtask_config.resolvedgainsworking_directory_prefix. An older pinned coder-eval reading these runs (for example thecoder-eval-uipathpipeline) rejects them, because the stop_reason Literal is closed and the models useextra="forbid". Existing nightly tasks keep their behaviour, becauseworking_directory_prefixdefaults to true. State this in the PR. (trigger: src/coder_eval/models/results.py)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] Widen CE022 (tests/lint/rules/ce022_dialog_loop_statement_cap.py) so it also caps the radon cyclomatic complexity of
Orchestrator._simulation_dialog_loop, and add_evaluation_loopas a second target. radon is already a runtime dependency. Set the caps to main's values (41 and 23), which are below the PR's values. The function keeps# noqa: PLR0915, and the statement cap of 128 lets a branch-only change through. Ruff's PLR0912 is the only other bound, so a change that adds branches but stays under the statement count passes today. The new message must say: build the DialogUser at the dispatch, do not add a None-default fallback inside the loop. Prevents: Finding 1 (orchestrator.py:2440): the PR raised _simulation_dialog_loop from F(41) to F(44) and _evaluation_loop from D(23) to D(24) and still passed CE022. The cause was the optionaluserfallback built inside the loop plus the twoself.grade andguards. - [ce-lint] New CE069, a closed-set mirror completeness rule in tests/lint/rules/ce069_closed_set_mirror_complete.py, wired in tests/lint/runner.py. CE067 is retired and must not be reused. CE068 is taken. Collect every
StrEnumin src/coder_eval. (a) Inreports/, a module-level dict literal whose str-constant keys include 2 or more values of one StrEnum must cover every value of that enum, or the code must be changed to amatch+assert_neverdispatch likeearly_stop_gate_notein reports/markdown.py. (b) ALiteral[...]annotation inmodels/whose string args overlap 3 or more values of one StrEnum must equal that enum's value set. Delete-before-guard: moveDialogStopReasonintomodels/and annotateSimulationTelemetry.stop_reasonwith the enum, so check (b) has nothing left to mirror. Prevents: Finding 13 (termination.py:16 / reports/html.py:1069):script_exhaustedis missing from_SIMULATION_STOP_REASON_LABELS, and the existingrun_limit_exceededgap is the same miss. It also catches the next hand-copy of thestop_reasonLiteral in models/results.py:432 drifting fromDialogStopReason. - [ce-lint] Pyright tightening: set
reportMatchNotExhaustive = "error"in [tool.pyright]. The standard mode leaves it off, so theassert_neveridiom is today only a runtime guard. With this setting, amatchoverDialogStopReason/FinalStatus/EarlyStopReasonthat is missing a member failsmake typecheck. This is the type-level partner of CE069'smatchoption. Prevents: Finding 13 (the missing SCRIPT_EXHAUSTED label), after the html label map becomes a match. It also prevents any later stop-reason / status renderer from falling through for a new enum member. - [ce-lint] New CE070, a mode-field partition check for SimulationConfig. Add it as a
@pytest.mark.lintclass in tests/test_custom_lint.py next to CE031, because it reflects over a model and does not walk one AST. Every name inSimulationConfig.model_fieldsmust be in exactly one of_FIXED_WHEN_SCRIPTED(simulator-only) or a new explicit_APPLIES_TO_SCRIPTallow-list (enabled,turns,max_turns,max_total_tokens,n_trials). The check fails when a field is in neither list, in both lists, or names a field that does not exist. The test must also stop copying the 7 names by hand at tests/test_simulation_config.py:198, and import the tuple instead. Delete-before-guard: the preferred fix is a discriminated scripted/simulated sub-model withextra="forbid", which deletes the tuple and this rule together. Prevents: Finding 2 (tasks.py:55): a fail-open deny-list where a forgotten simulator-only field is accepted besideturnsand does nothing. It also prevents Finding 15 (tasks.py:310), because a partition forces an explicit decision on whethermax_turnsapplies to a script, and it would have exposedmax_turnsas a redundant second source for script length. - [ce-lint] New CE071, a single owner for the dialog-mode predicate, in tests/lint/rules/ce071_dialog_mode_predicate.py. Forbid the attribute chain
<x>.simulation.enabledand<SimulationConfig>.turns is None/is not Noneoutside src/coder_eval/models/tasks.py. Consumers go through named properties on TaskDefinition, for exampleruns_dialog,uses_llm_simulatorandreads_criteria_mid_dialog. Sites to move (on pr-246): run_command.py:994, isolation/egress.py:130, orchestrator.py:2167-2168, harbor/packager.py:178, orchestration/early_stop.py:124, orchestration/task_loader.py:189. Prevents: Finding 11 (run_command.py:994 / egress.py:130 / orchestrator.py:2168): the scripted-vs-simulated predicate is written again in each consumer. Finding 20 (tasks.py:793): the grading-network refusal and the harbor packager still treat a scripted dialog as one that reads criteria mid-dialog, because they were not routed through a shared predicate. - [ce-lint] New CE072, one writer for turn-derived result facts, in tests/lint/rules/ce072_turn_facts_single_writer.py. Assignment to
self.result.max_turns_exhausted(and to any EvaluationResult field copied from a TurnRecord) is allowed only inside oneOrchestrator._record_turn_facts(turn_record)helper, plus the resume copy at orchestrator.py:708, which is exempt. Delete-before-guard: the helper is called right afterself.result.iterations.append(turn_record)on the single-shot path and the dialog path, so no stop-decisionbreakcan come between the turn and the recording of its facts. The rule finds the duplicated writer sites (orchestrator.py:2210 and 2618) statically. The ordering inside the loop needs the runtime test in the harness bucket. Prevents: Finding 17 (orchestrator.py:2608, high): the SCRIPT_EXHAUSTED break runs beforeself.result.max_turns_exhausted = True, so a scripted dialog whose agent exhausts max_turns on the last turn is classified FAILURE and not MAX_TURNS_EXHAUSTED. - [ce-lint] New CE073: in src/coder_eval/models/, no validator may compare a field to its current code default by value. This forbids
.get_default(calls and== / != DEFAULT_*comparisons inside@model_validator/@field_validatorbodies. Every recordedtask_config.resolvedgoes through these validators again onevaluate/--resume, so a check that depends on a default turns a later default change into a reload failure. Express 'not authored' as aNonedefault that is resolved at use time. Only one site exists today (tasks.py:306), so the rule is cheap. The shape is greppable, and the fix removes the only occurrence. Prevents: Finding 19 (tasks.py:306): when DEFAULT_JUDGE_MODEL changes, every earlier scripted run failsTaskDefinition.model_validate(record.resolved)and regrade falls back to source. Also Finding 16 (tasks.py:55): an inheriteddefaults.simulation.modelbreaks every scripted task in a mixed suite. - [ci-gate] Add a diff-coverage gate to
make verify/ CI, for examplediff-cover coverage.xml --compare-branch=origin/main --fail-under=90, limited to changed lines under src/coder_eval/. The 80% global threshold cannot see one uncovered branch in a PR-modified hunk. This gate makes 'new branch shipped untested' a mechanical failure and not something a reviewer has to remember. Prevents: Finding 6 (orchestrator.py:2310-2311): the ungraded-dialog backstop that the PR narrowed is uncovered in a modified hunk. It partly prevents Finding 7 (no test runs a script shorter than max_turns) and Finding 8 (the prompt_mutations/initial_prompt_file paths on a scripted task).
Harness improvements (not statically reachable):
- Add a stop-path x exhaustion-turn parity test matrix in tests/test_scripted_dialog.py. For each dialog end (SCRIPT_EXHAUSTED, MAX_TURNS, STOP_TOKEN, CRITERIA_PASSED), make the
_SessionAgentfixture exhaust its agent max_turns on the first, a middle and the last turn, and assert the same FinalStatus andresult.max_turns_exhausted. Also assert that a one-turn script and the equivalent single-shot task produce the same FinalStatus and result facts, underrunand underexecutefollowed byevaluate. Why not static: The defect is the run-time order of a stop-decisionbreakand a fact assignment inside one loop iteration. CE072 can force a single writer, but only running the loop shows which statement runs first on which exit. Prevents: Finding 17 (orchestrator.py:2608, high). It also gives the orchestrator test with a default max_turns (script shorter than the cap) that Finding 7 asks for. - Add a guard after dataset expansion. When
expand_datasethas run, walk every string leaf of each expanded task'smodel_dump()and raise if a${row.placeholder survives. Today onlyinitial_promptandsuccess_criteriaare substituted, so this guard makes a field outside that list (nowsimulation.turns, tomorrow any other prompt-bearing field) fail loud and not reach the agent verbatim. Pair it with a DATASETS.md 'what is substituted' entry forsimulation.turns, or with a validation error fordataset:+simulation.turns. Why not static: The placeholder is in user-authored YAML data, not in source code. Only the loaded and expanded task shows whether a row placeholder is left in a field that substitution does not cover. Prevents: Finding 18 (tasks.py:740 / task_loader.py:436-440, high): N dataset rows grade row-specific criteria against one unrendered${row.q}prompt, and nothing warns. - Add a round-trip invariant for the resolved config. At the end of
resolve_all_tasks, after_apply_prompt_overrides, CLI overrides and dataset expansion, requireTaskDefinition.model_validate(task.model_dump())to succeed, and raise a clear error that names the layer. This makes sure that a TaskDefinition invariant broken by a later in-place layer (TaskDefinition has no validate_assignment) fails at resolve time, and that a run is never recorded with a config thatevaluate/--resumecannot reload. Back it with a parametrized resolver test on a scripted fixture over: variantinitial_prompt, variant/defaultsprompt_mutations, variantinitial_prompt_file,defaults.simulation.model,defaults.simulation.check_criteria,-D simulation.turns=[...],-D working_directory_prefix=false, and a variant that addsturnsto a persona/goal task. Why not static: The invariant breaks only when one merge layer's data meets a later layer's in-place mutation. There are many legitimatetask.<field> =assignments in orchestration/, so an AST rule cannot separate safe ones from unsafe ones without the merged values. Prevents: Finding 10 (tasks.py:740): a variant initial_prompt on a scripted task is silently ignored and the recorded config cannot be reloaded. Finding 8 (misleadinginitial_prompt must be resolved before applying mutations). Finding 9 (no defaults / -D coverage for the new layered fields). - Add a forward-compatibility test for recorded configs. Load a checked-in
task_config.resolvedfixture for a scripted run, monkeypatch each simulator code default (DEFAULT_JUDGE_MODEL / DEFAULT_SIMULATOR_MODEL, stop_token), and assert thatorchestration/regrade.pyreloads it without taking_fall_back_to_source. Why not static: The failure is temporal: the recorded config was valid when it was written and becomes invalid only after a later default change. Seeing it needs the persisted artifact plus a changed default at run time. Prevents: Finding 19 (tasks.py:306): old scripted runs silently regrade from source after a default bump. - Make a mixed-suite resolution regression fail loud. Add an experiment fixture that holds a single-shot task, an LLM-simulated task and a scripted task under
defaults:that pinsimulation.modeland setprompt_mutations, and assert that no task goes toskipped. Also consider counting resolution-skipped tasks in run.json / the summary, so a suite that gets smaller is visible to the external eval-runner consumer and not only in a log warning. Why not static: The skip happens only when experiment-level defaults are merged with a specific task mix at resolve time. The code that catches the ValueError is correct by itself. The defect is the data combination. Prevents: Finding 16 (tasks.py:55): scripted tasks are quietly moved toskippedwhen experiment defaults set a simulator knob or prompt_mutations. Finding 15 (tasks.py:310): a script longer than 8 turns is rejected until max_turns is raised. - Add a test that trips each grade-disabled backstop. For every
if not self.grade: raisein orchestrator.py, require a test that reaches it: an LLM-simulatedcheck_criteria='every_turn'dialog with grade=False raises, and an LLM-simulated end_of_dialog dialog with grade=False finalizes NOT_GRADED with emptysuccess_criteria_results. In the same file, replace the duplicatetest_script_exhausted_is_reported_instead_of_max_turnswith a_scripted_cfg(max_turns=8)case, and add a test that a ScriptedUser cursor overrun propagates and is not logged as a 'simulator failure'. Why not static: These are behaviour assertions on paths that are reachable only past the CLI refusal, or only when two owners of 'end of script' disagree. A lint rule can see that a backstop exists, but not that a test exercises it. The diff-coverage gate finds the uncovered line, and these tests pin the contract. Prevents: Finding 6 (orchestrator.py:2310). Finding 7 (test_simulation_termination.py:117). Finding 14 (scripted_user.py:37): the IndexError is swallowed by the blanket simulator except. - Record the boundary of static analysis for the remaining findings in .claude/harness-candidates.md, so it is a deliberate decision. (a) A
str | Nonevalue interpolated into an f-string (UserSimulator{config.persona}) is not flagged by any pyright setting, and a CE rule would need type information. The fix is to narrow once at the UserSimulator boundary. (b) Stale 'LLM user simulator' naming, the invertedverbatimtest flag, andDialogUseras a closed union and not a Protocol need semantic judgment and stay reviewer-owned. Why not static: Detecting these needs inferred types through f-string formatting, or a judgment about naming and abstraction, which an AST or grep cannot make reliably. Prevents: Finding 5 (tasks.py:199), Finding 3 (orchestrator.py:2443), Finding 4 (test_scripted_dialog.py:90), Finding 12 (simulation/init.py:27). They are recorded as reviewer-owned, not as mechanically prevented.
Top 5 Priority Actions
- In src/coder_eval/orchestrator.py:2602-2619, set
self.result.max_turns_exhausted = Trueright after the turn record is appended and before theevaluate_stopbreak, because SCRIPT_EXHAUSTED is now the normal scripted exit; today an agent that hits its max_turns on the last scripted turn is graded FAILURE instead of MAX_TURNS_EXHAUSTED. Add a test withexhaust_on_call=len(TURNS)(tests/test_scripted_dialog.py:316 covers only call 1). - Substitute
${row.*}into everysimulation.turnsentry inexpand_dataset(orchestration/task_loader.py:436-440), or rejectdataset:together withsimulation.turnsat validation. Today tasks.py:740 forbidsinitial_prompt, so every dataset row sends the same literal placeholder text while its criteria are row-specific, and nothing warns. - Make
_apply_prompt_overrides(orchestration/experiment.py:278-298) handle scripted tasks: reject a variantinitial_prompt/prompt_mutationswith an error that namessimulation.turns, or apply them to the turns on purpose. Then validate the whole TaskDefinition again. Today a variant prompt is silently ignored (orchestrator.py:2173), and the recorded config fails the tasks.py:740 validator whenevaluateor--resumereloads it. - Replace the by-value default comparison over
_FIXED_WHEN_SCRIPTED(src/coder_eval/models/tasks.py:55-63, 306) with None-default simulator-only fields, an allow-list, or a discriminated scripted/simulated sub-model. Today a futureDEFAULT_JUDGE_MODELchange breaks regrade of recorded scripted runs (regrade.py:102 falls back to source),defaults.simulation.modelorcheck_criteriaquietly moves scripted tasks toskipped, and a new simulator field left off the list fails open. - When
turnsis set, derive the dialog cap fromlen(turns)and remove themax_turns >= len(turns)check (src/coder_eval/models/tasks.py:310-314), the matching notes in DIALOG_MODE.md:106 and TASK_DEFINITION_GUIDE.md:1809, and the wrong/max_turnsdenominator in the progress log at orchestrator.py:2544. Add tests for a script shorter than the default max_turns and for the ungraded backstop at orchestrator.py:2310.
Stats: 0 🔴 · 2 🟠 · 7 🟡 · 11 🔵 across 8 axes reviewed.
|
Claude finished @cosminacho's task in 1m 31s —— View job Code Review for PR #246: feat(simulation): add scripted user turnsTodo List:
|
bai-uipath
left a comment
There was a problem hiding this comment.
Approve, with one fix before merge. Reusing the existing dialog loop with a drop-in scripted user is the right shape.
- Variant prompt overrides are silently ignored on scripted tasks (before merge): the variant
initial_promptis applied by plain attribute assignment, so the new "no prompt besideturns" validator never runs. The run usesturns[0]and drops the variant prompt without a word, and the resolved record then fails validation on reload, soevaluate/run --resumeon that run crash in regrade. Reproduced on 31f4c63. Fix: reject a variantinitial_prompton scripted tasks when overrides are applied;prompt_mutationsalready fails there, but with a confusing "initial_prompt must be resolved" error, so give it the same clear message. max_turnsis a second source of truth: with the default of 8, a 9-question script fails validation until the author also raisesmax_turns. Fix: derive it from the script length for scripted dialogs.
Minor: model on the must-stay-default list means an experiment that pins simulation.model suite-wide rejects every scripted task in it; persona / goal are now optional at the type level, with the invariant enforced only by the validator.

Adds
simulation.turnsfor authored prompts sent verbatim, in order, in one agent session, without an LLM user. Tasks can disable the working-directory prefix when exact prompt text matters. Dataset values expand in each turn; execution artifacts retain the authored dialog and raw agent-limit facts.A script owns its dialog length (1–100 turns), so
max_turnsis derived from the list. Unsupported variant prompt replacements and mutations fail before changing a scripted task. Existing simulated-user defaults and agent run limits stay unchanged.Validation: 138 directly affected test cases passed across guarded runs; the existing CLI case used an owned empty experiment and disabled Python provisioning. Nineteen independent resolver/run/reload controls passed. Changed production files passed type checking; Ruff, prose checks, and applicable architecture rules passed. No full-suite claim.