Skip to content

feat(bench): multi-turn agent harness — read-only tools, loop, runner wiring (#102/#103)#119

Merged
Connorrmcd6 merged 3 commits into
mainfrom
feat/102-agentic-tools-runtime
Jun 14, 2026
Merged

feat(bench): multi-turn agent harness — read-only tools, loop, runner wiring (#102/#103)#119
Connorrmcd6 merged 3 commits into
mainfrom
feat/102-agentic-tools-runtime

Conversation

@Connorrmcd6

Copy link
Copy Markdown
Owner

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-neutral TOOL_SPECS for the upcoming adapter translation. Tracks accessed paths + a touched_hidden() helper — the basis of the verification-rate metric. scenario_sandbox() yields a fresh copy of code/ that includes the hidden dependency the prompt withholds.
  • agent.pyrun_agent() drives a ToolModel over the tools with a documented 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 byte-identical to single-shot and the two modes stay comparable.
  • models.py — neutral Step/ToolCall types and a ToolModel protocol (step(...)), kept separate from the single-shot Model so existing adapters need no change. Step.provider_msg lets each future adapter echo history losslessly. MockToolModel for offline runs.
  • run.py--mode {single,multi} (+ config mode, default single so 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 with mode, and records mode/max_turns in run.json. A guard exits early if multi mode is asked of a model without step().
  • config.tomlmode + max_turns keys.
  • tests — 20 offline tests: the tool surface, the loop (incl. an end-to-end run against the real cascade-quota-batcher sandbox), 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 (in bench/) — 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).
  • Manual smokes: --mode single is an unchanged regression; --mode multi produces rows with all agent fields correctly populated (e.g. stop_reason=final_answer, verified_hidden plumbed end-to-end) and run.json records mode/max_turns.

🤖 Generated with Claude Code

Connorrmcd6 and others added 3 commits June 14, 2026 09:46
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>
@Connorrmcd6 Connorrmcd6 merged commit 9f861cc into main Jun 14, 2026
5 checks passed
@Connorrmcd6 Connorrmcd6 deleted the feat/102-agentic-tools-runtime branch June 14, 2026 16:17
@Connorrmcd6

Copy link
Copy Markdown
Owner Author

Added: Anthropic step() adapter (phase 4, Anthropic only).

This extends the PR beyond the offline foundation: AnthropicModel is now a ToolModel, so a real Claude model can drive the loop. The translation between the neutral loop format and the Anthropic wire format is in pure, unit-tested converters (_anthropic_tools / _anthropic_messages / _step_from_anthropic) — no network in the test suite (4 new offline tests, 24 total green). OpenAI + Gemini adapters remain separate (#107).

Live smoke (haiku · multi · cascade-quota-batcher-code · C0/C1/C2 · N=1) confirms the real round-trip and already previews the headline:

Cond verified_hidden turns result
C0 (no doc) ✅ read window.py 4 correct
C1 (stale doc) 1 misled
C2 (fresh doc) 2 (n=1 noise)

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant