feat(bench): multi-turn agent harness — read-only tools, loop, runner wiring (#102/#103)#119
Conversation
Phase 1 of the v2 multi-turn track: the foundation that lets the agent *choose* to read a doc's hidden dependency instead of being forced to trust the doc (the single-shot cascade's structural limit). - tools_runtime.py: ToolContext with read-only tools (read_file, grep, list_dir) + a final_answer terminator, path-scoped to a sandbox with escape rejection. dispatch() returns (result, is_final) and turns bad calls into recoverable error strings. Provider-neutral TOOL_SPECS for the upcoming adapter translation. Tracks accessed paths and a verified() helper — the raw material for the verification-rate metric. - scenario_sandbox(): a fresh per-trial copy of code/ that INCLUDES the hidden dependency, so a tool call can reach what the prompt withholds. - tests/: 13 offline tests (no API/spend), incl. an integration check that a real cascade scenario's hidden file is absent from the prompt yet reachable via tools in the sandbox. - pyproject: pytest pythonpath/testpaths config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of the v2 multi-turn track: turn the read-only tools into an actual agent, still fully offline (no API/spend). - models.py: neutral Step/ToolCall dataclasses and a ToolModel protocol (step(system, messages, tools) -> Step), kept separate from the single-shot Model protocol so existing adapters don't need step(). Step.provider_msg carries the raw assistant message so each future adapter can echo history losslessly. MockToolModel returns a scripted sequence of Steps and falls back to a text answer when exhausted. - agent.py: run_agent drives a ToolModel over the tools with a neutral message history. Terminates on final_answer, a text-only turn, or a forced answer-now nudge at max_turns. Returns a Trajectory (final_text, turns, stop_reason, tool_calls, accessed paths, per-turn tokens) — the final answer still carries VERDICT/FILE blocks so grading is unchanged. - tests: read-then-final (verification registers), text-only answer, bad-call-then-recover, max_turns forced answer, and an end-to-end run against the real cascade-quota-batcher sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: the matrix runner can now drive the agent loop, still offline.
- run.py: --mode {single,multi} (+ config `mode`) and --max-turns. In
multi mode each trial gets a fresh scenario_sandbox + ToolContext and
goes through run_agent; the final answer is graded by the SAME graders,
so modes stay comparable. New per-row fields (multi only): turns,
stop_reason, tool_calls, verified_hidden, per_turn_tokens; every row is
tagged with mode. run.json records mode + max_turns. A guard exits early
if multi mode is asked of a model without step() (so the eventual
provider adapters fail loud, not deep in the loop).
- models.py: build_model takes mode; mock+multi yields a MockToolModel
that answers with the canned per-condition reply (so the offline smoke
flows end to end). MockToolModel gains set_condition/replies parity with
MockModel.
- tools_runtime.py: touched_hidden() module helper (verification metric)
the runner uses on a Trajectory's accessed paths.
- config.toml: mode + max_turns keys (default single, preserving v1).
- tests: in-process runner integration — multi writes the agent fields,
single omits them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Added: Anthropic This extends the PR beyond the offline foundation: Live smoke (haiku · multi ·
i.e. with no doc the agent goes and checks the hidden dependency; with a confident stale doc it answers immediately and is misled. The real signal awaits N≥10 + the oracle tripwires (#105/#117). |
Summary
Phases 1–3 of the v2 multi-turn ("agentic") track — the offline foundation that lets a model choose to read a doc's hidden dependency instead of being forced to trust the doc (the single-shot cascade's structural limit). This is the groundwork for measuring verification suppression: does a confident stale doc stop an agent doing the file-read it otherwise would?
No API calls, no spend — everything here runs on a
MockToolModel. The first step that touches real keys/money is the provider adapters (phase 4, Connorrmcd6/surface-bench#16), deliberately left out so this can be reviewed/merged as a self-contained unit.What changed
tools_runtime.py— a read-only tool surface (read_file,grep,list_dir,final_answer), path-scoped to a per-trial sandbox with escape rejection.dispatch()turns bad calls into recoverable error strings. Provider-neutralTOOL_SPECSfor the upcoming adapter translation. Tracks accessed paths + atouched_hidden()helper — the basis of the verification-rate metric.scenario_sandbox()yields a fresh copy ofcode/that includes the hidden dependency the prompt withholds.agent.py—run_agent()drives aToolModelover the tools with a documented neutral message history. Terminates onfinal_answer, a text-only turn, or a forced answer-now nudge atmax_turns. Returns aTrajectory(final text, turns, stop_reason, tool calls, accessed paths, per-turn tokens). The final answer still carriesVERDICT:/FILE:blocks, so grading is byte-identical to single-shot and the two modes stay comparable.models.py— neutralStep/ToolCalltypes and aToolModelprotocol (step(...)), kept separate from the single-shotModelso existing adapters need no change.Step.provider_msglets each future adapter echo history losslessly.MockToolModelfor offline runs.run.py—--mode {single,multi}(+ configmode, defaultsingleso v1 is untouched) and--max-turns. Multi mode runs each trial through a fresh sandbox +run_agent, adds per-row fields (turns,stop_reason,tool_calls,verified_hidden,per_turn_tokens), tags every row withmode, and recordsmode/max_turnsinrun.json. A guard exits early if multi mode is asked of a model withoutstep().config.toml—mode+max_turnskeys.cascade-quota-batchersandbox), and in-process runner integration proving multi writes the agent fields and single omits them.Why not "Closes"
Part of #102 (agentic loop harness) and #103 (sandboxed execution + per-turn instrumentation). It doesn't fully close them: a real model can't run multi mode until
step()lands on the provider adapters (#107), and the verification/compounding metrics + reporting are Connorrmcd6/surface-bench#13. Kept scoped so the offline core merges before any spend.Verification
uv run pytest(inbench/) — 20 passed.bench/is Python and lives outside the cargo workspace, so this is the authoritative gate for the change.cargo fmt --all --check— clean (no Rust touched).--mode singleis an unchanged regression;--mode multiproduces rows with all agent fields correctly populated (e.g.stop_reason=final_answer,verified_hiddenplumbed end-to-end) andrun.jsonrecordsmode/max_turns.🤖 Generated with Claude Code