AgentCanvas / Pages / Developer Guide / Design Docs / Coding-Agent Runner
2026-07-20

The coding-agent runner is the machinery behind the Coding-Agent Harness: one shared core (coding-agent/driver.py) that runs any harness × model × condition cell of the standard board. Its hard promise is that the harness is the only moved variable — episode placement, prompts, the tool surface, evaluation, retry policy, and the artifact layout are one implementation shared by all three harnesses and both entry points, so two cells differ in nothing but what their names say. For what the experiment measures, read the capability page; for the frontend windows onto the artifacts, the Monitors group; for run recipes, coding-agent/README.md.

One core, two entries, three adapters. Both entries resolve to run_cell; every adapter runs one clean session per episode against the same tool surface; only EventSink writes the log vocabulary. The frozen legacy drivers gate the live surface from below.

stdrun.py — CLI run·batch·board·compare·drain uirun.py Monitor Run button (ui_*) driver.py · run_cell cells.py — CellSpec · STD_FROZEN prompts.py — briefing · md5 gate EventSink — episode_i.jsonl vocab N workers · per-index resume · DRAIN rate-limit retry (paused clock) driver-side env_habitat__evaluate harnesses/claude_sdk.py harnesses/mini_swe.py harnesses/codex_cli.py core → adapter: 1 clean session per episode one tool surface bridges/mcp_bridge.py — MCP stdio mini/toolset.py — in-process sdk·codex spawn it tools: mcp__env__* env_habitat auto_host HTTP POST /call/{fn} outputs/<harness root>/<run>/ episode_i.jsonl · raw/ · live_i/ summary.json (aggregate 口径) flush per event legacy/ — frozen drivers check_equivalence fixtures (provenance; never edited) byte-equal gates

1. What it does

It runs one cell — a (harness × model × condition × effort-tier) point of the standard board — end to end: place episodes on an env_habitat auto_host, render the frozen briefing, hand each episode to the harness adapter for one clean agent session, score it with habitat's own env_habitat__evaluate, and write the uniform artifact set the Monitor renders. Before unification (2026-07-20) each harness carried its own full driver; the shared 90% now lives here once, and each harness is a ~100-line adapter. The smallest use is one command — a cell name pins every protocol knob:

# a cell name pins everything; the only free choices are which cell, which servers
python coding-agent/stdrun.py run std_sdk_fable-5_bare_default
python coding-agent/stdrun.py run E17            # paper E-numbers resolve to cells
python coding-agent/stdrun.py board             # protocol truth lives on disk, not in names

2. One episode through the core

run_cell owns everything an episode needs except the agent loop itself (driver.py · run_cell / run_episode):

  1. Placement — dataset + split pushed once per server, then per episode: episode_index field + play + env_habitat__reset (carrying the frozen rgb_resolution). All env HTTP rides asyncio.to_thread so parallel workers never stall the loop.
  2. Briefingprompts.py · build_briefing renders the frozen 07-09 drafts (BARE / FULL / WP) with the instruction and budget; a nav cell first passes assert_std_skill_freeze, which refuses to run if the skill body's md5 drifted from f7c74272.
  3. Session — a fresh EpisodeContext (instruction, briefing, budgets, bridge env vars, live/raw dirs) goes to adapter.run(ctx, sink); the adapter may emit only through the EventSink, which enforces the log vocabulary by construction and derives tool-call counts + the last step() result uniformly.
  4. Scoring — the driver (never the agent) calls env_habitat__evaluate while the trajectory file is still open, so metrics land inside the log; summary.json is rewritten after every episode with aggregate applying the board 口径 (an evaluated episode scores as-is; an unevaluated timeout scores 0; rate_limited and non-engaged infra errors are excluded, driver.py · aggregate).
  5. Batch management — N workers pull from one index queue (one env server each, staggered cold loads); --episodes reruns/resumes specific indices into the same run dir; a DRAIN sentinel / SIGUSR1 finishes in-flight episodes and prints the ready-to-paste resume spec; subscription throttling backs off outside the episode's timed scope (the "paused countdown") for up to 6 attempts; a VramSampler records the GPU peak.

3. Who calls what — one door, two clients, one translator

Everything reaches the environment through a single HTTP door — the auto_host surface — but its two clients hold different vocabularies. The driver talks to the door directly and owns the full vocabulary: episode selection and play on the env panel, then env_habitat__reset before the session and env_habitat__evaluate after it (driver.py · run_episode, signing its calls with a literal {"trigger": "driver"}). The agent never sees that door. It speaks MCP to the bridge, and the bridge — itself just another HTTP client of the same door (mcp_bridge.py · _call) — translates exactly two node types: env_habitat__observe_egocentric and env_habitat__step_discrete (look_around is composed from them: spin-in-place turns, one frame per stop). reset and evaluate are not blocked by permission — they simply do not exist in the translated vocabulary, so no harness can reset mid-episode or peek at its score. mini takes the same subset through an in-process shortcut instead of MCP (mini/toolset.py, byte-equal gated in §6). Inside the server, state lives in exactly one place: the per-process HabitatEnvManager singleton (env_habitat.py · HabitatEnvManager.get), whose single-thread executor pins habitat's GL context. The worker structure is what makes this safe: run_cell spawns one worker per env server, workers pull episode indices from one shared queue, and each worker runs its episodes serially against the one auto_host it owns exclusively for the whole run (driver.py · run_cell) — so a manager never faces two live sessions, without any locking. Parallelism is process replication (N workers ⇒ N auto_host processes, one manager each); the agent session and its bridge/toolset are born fresh per episode under their worker and die with it.

Entries → workers → the door. Both entries resolve to run_cell, which spawns one worker per env server off a shared episode queue; each dashed box below is one worker — serial episodes, exclusive owner of its auto_host. Inside a worker, the driver and the tool surface are both HTTP clients of that one auto_host door; the agent itself never touches HTTP — it speaks MCP to the bridge (or direct python to mini's toolset), and the translated vocabulary simply lacks reset and evaluate.

env_habitat auto_host k bound 1:1 to worker k — exclusive the one HTTP door POST /call/{fn} · /env-panel/… canvas tool nodes — thin adapters HabitatEnvManager — singleton world state: pose · steps · scene single-thread executor (GL affinity) one manager per process — never two never restarted between episodes auto_host × N — one per worker stdrun.py — CLI uirun.py — Monitor driver.py · run_cell shared episode queue 0…99 spawns N workers worker k — serial: one episode at a time driver.py · run_episode before: episode_index · play · reset after: evaluate — never the agent HTTP — full vocabulary agent session sdk · codex — speaks MCP mcp_bridge.py translator — subset only MCP (stdio) HTTP — observe · step only agent loop (mini) DefaultAgent — no MCP mini/toolset.py in-process · byte-equal python same HTTP subset session + bridge/toolset: fresh per episode, die with it reset · evaluate never enter the MCP vocabulary — the agent cannot say them
CallerCalleeInterfaceVocabulary
driver.py · run_episodeauto_hostHTTPfull — env-panel episode_index + play · reset · evaluate
agent session (sdk · codex)bridges/mcp_bridge.pyMCP over stdioobserve · step (+ look_around off-BARE)
bridges/mcp_bridge.pyauto_hostHTTP (_call)observe_egocentric · step_discrete — nothing else
agent loop (mini)mini/toolset.pyin-process pythonsame subset, byte-equal gated (§6)
canvas tool nodesHabitatEnvManager_run_sync → single-thread executoreverything — all world state lives here

4. The adapter contract

An adapter is the harness-specific residue after the core took its 90% — the HarnessAdapter protocol is four methods (driver.py · HarnessAdapter): prepare (once per run: auth guards, version pins into the recorded inherent dict, any serving stack the harness owns — raising aborts the cell, because a silently degraded server is worse than no run), describe (the session-config block logged into session_inputs), run (one clean session, events through the sink only), and optional finalize (the audit only that harness can produce).

AdapterLoopHard-won specifics it encapsulates
claude_sdk.pyClaude Agent SDK (closed)strips a stray ANTHROPIC_API_KEY (subscription auth; STD_SDK_USE_API=1 opts back into metered billing); gates the first prompt on the bridge reporting MCP-connected; strict_mcp_config + setting_sources=[] so no user MCP servers or CLAUDE.md leak into sessions; 32 MiB stdout buffer for look_around()
mini_swe.pymini-swe-agent ReAct (open)in-process NodesetToolSet instead of a bridge subprocess; for local models it owns ollama — context pinned to 131072 (past it ollama truncates silently), sampling read back from /api/show and refused if not Modelfile-pinned; finalize slices the serve log for exact per-request prompt-token counts
codex_cli.pyOpenAI Codex CLI (closed)codex exec --json per episode on the ChatGPT subscription; MCP approval mode pinned (v0.142 silently rejects the documented "auto"); reasoning is usage-counted but encrypted; no SDK-level turn cap exists (see §7)

5. The registry — cells, not flags

Comparability is enforced structurally: protocol knobs live in cells.py, not on the command line. STD_FROZEN pins std-v2 (R2R-CE rand100 eps 0–99 · 200 turns · 512 px · 500 actions · 2400 s); CONDITIONS defines bare / nav / persona / wp / wp-nav; EFFORT_TIERS runs each main cell at default and max, with _tier_extra resolving what each label concretely means per harness; MODELS + MODEL_ID_OVERRIDE map board columns to harness-facing slugs; BATCHES names sequential cell lists (treatment-before-control ordering for cuttable local batches); EXPERIMENTS maps the paper's E-numbers onto cells, and resolve_cell accepts either form. Any override requires --nonstd, which renames the run nonstd_* so it can never sit on the board (stdrun.py · _run); the Monitor's uirun.py gets the same treatment structurally — its free knobs make every UI run ui_*, off-board by name. compare runs an exact paired McNemar over same-episode successes, refusing pairs whose episode_ids diverge (stdrun.py · _compare).

6. Freeze & equivalence gates

"Same experiment across harnesses" is asserted, not assumed. Three gates: (a) the skill md5 freeze (§2); (b) mini/check_equivalence.py proves the mini in-process toolset byte-equal to the bridge — tool names, descriptions, schemas, clearance math, waypoint geometry, down to identical annotated-PNG bytes; (c) the same script asserts the live prompts.py texts byte-equal to the frozen legacy SDK driver they were moved from. The fixtures are the point of coding-agent/legacy/: the pre-unification drivers are kept unedited so the live surface can always be diffed against what produced the archived runs.

7. Where it deviates from the mental model

Output roots still say beta-*. The package unified in coding-agent/, but runs land in the pre-unification roots (outputs/beta-coding-agent · beta-react-harness · beta-codex-agent, cells.py · OUTPUT_ROOTS) — deliberately, so the Monitor's source toggle, the board aggregation, and all run history survived the move unchanged.
A cell name does not carry its protocol. Turns went 80→200 and rgb 224→512 under stable cell names; two runs of the same name can be different protocols. The board prints turns/rgb from each run's recorded config precisely because of this — read the board, never the name, before comparing (stdrun.py · _board).
Cross-vendor effort tiers are labels, not equivalents. default resolves to "no effort param" on the SDK, medium on codex/mini-GPT — except codex + gpt-5.6, whose real CLI default is low; max means Claude effort="max" but GPT xhigh. The labels are policy; actual thinking spend lives in the per-call usage logs (cells.py · _tier_extra).
The same board column can ride different slugs. codex reaches gpt-5.6 only as the ChatGPT-account variant gpt-5.6-sol (MODEL_ID_OVERRIDE); whether it is bit-identical to plain gpt-5.6 is unverified, so the codex↔mini comparison on that column carries a recorded slug asymmetry.
codex has no hard turn cap. ctx.max_turns feeds only the bridge's budget broadcast and STOP gate there; the binding limits are the 500-action step budget and the episode timeout. A recorded harness-inherent difference, not a bug (codex_cli.py docstring).

8. Key files

PieceWhere
Corecoding-agent/driver.pyrun_cell · run_episode · EventSink · aggregate
Registrycoding-agent/cells.pyCellSpec · STD_FROZEN · CONDITIONS · BATCHES · EXPERIMENTS
Prompt surfacecoding-agent/prompts.py — frozen drafts · skill loader · md5 gate
Adapterscoding-agent/harnesses/claude_sdk.py · mini_swe.py · codex_cli.py
Entriescoding-agent/stdrun.py (CLI) · coding-agent/uirun.py (Monitor)
Tool surfacecoding-agent/bridges/ · coding-agent/mini/toolset.py (+ check_equivalence.py)
Frozen fixturescoding-agent/legacy/ — pre-unification drivers, never edited
AgentCanvas docs