Skip to content

feat(v1): multi-agent topologies — imperative go(), agent graphs, deferred cross-agent rewards#1941

Draft
eligotts wants to merge 16 commits into
mainfrom
verifiers-topology
Draft

feat(v1): multi-agent topologies — imperative go(), agent graphs, deferred cross-agent rewards#1941
eligotts wants to merge 16 commits into
mainfrom
verifiers-topology

Conversation

@eligotts

@eligotts eligotts commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Multi-agent environments for v1, in three layers on top of main's task-first core (this branch merges the landed #1948 and builds strictly above it — the diff is near-pure addition):

  1. Agent — the execution primitive: harness × model context × runtime policy, one arrow run(task) -> Trace, plus provision() (share a runtime box between agents) and interact() (hold a live episode open and converse with it).
  2. Topology — the config-driven packaging: named agents as typed config fields, orchestration as plain imperative code in go(), cross-agent rewards declared and deferred, each instance persisted as an AgentGraph.
  3. Seven instantiations — two built-ins (llm-judge, agentic-judge) and five example envs, each emblematic of one contract, all verified live.

Core in agent.py / topology.py; guide at docs/v1/topologies.md.

Built on the task-first contract

A topology's whole premise is main's #1948: a task minted anywhere is first-class (its TaskData row + its behavior class). go() mints tasks mid-run three equal ways — a taskset's load(), inline CritiqueTask(vf.TaskData(...)), or the Task.from_trace(trace) convention #1948 documents: a task derived from a finished rollout, reading everything off the self-describing trace (trace.task.data). The judge topologies and proposer-solver implement from_trace verbatim.

go(): orchestration as plain imperative code

An agent is pure routing (harness + model/client/sampling + trainable); tasks arrive per episode. The forward arrow is ordinary host-side construction; loops are rounds, asyncio.gather is fan-out, awaiting several traces is fan-in:

async def go(self, task, run):
    proposer = await run.agent("proposer").run(task)
    derived = SolverTask.from_trace(proposer)          # trace -> task, off info["submission"]
    await asyncio.gather(*(
        run.agent("solver").run(derived, parents=[proposer])
        for _ in range(self.config.num_solvers)))

Episode failures never raise — they return as data on the trace, and go decides what a failed child means.

Sessions: agents interacting within each other's episodes

run() composes completed episodes; back-and-forth interaction (chess, debate — each agent effectively the other's user) needs episodes held open. run.agent(name).interact(task) yields a Session with exactly three members: turn(message) -> reply, end(), .trace. The episode suspends between turns; N sessions + imperative routing compose into alternation, simultaneous moves (gather over turns), moderated rooms, and hidden-information views — no scheduler, no message bus.

Mechanically a session is a user simulator without the server: the interception layer already suspends an episode between turns awaiting the user seat's Respond; a session supplies that callable over an in-process queue handshake (Rollout(user=)). The turn loop, budgets, @stops, and trace building are reused unchanged. The safety contract is loud, not patient: turn() on a dead episode raises SessionEnded (never hangs), double-driving and cancelled-mid-flight turns are refused, prompted tasks / own-Task.user tasks / non-user-capable harnesses are refused at the call, and there is no retry= — half-played interactions can't be re-run. Each seat's trace is ONE multi-turn trajectory with its counterparts as user turns — the self-play training-sample shape, not one episode per move.

Deferred rewards: task-tied or topology-declared

Task rewards run at episode end, inside the rollout, runtime still live (main's contract — including config.judges). Topology rewards are declared @vf.reward(agent=...)/@vf.metric(agent=...) methods, run at instance end once per matching trace — which is what makes "proposer reward = distance of solver pass-rate from 50%" expressible. Scopes are validated at load; metrics run before rewards; both land in the same trace.rewards under distinct keys (verified live: {'correct': 1.0, 'judge': 1.0}).

Seeds, the agent graph, and full config addressability

  • Seeds: --topology.taskset.id (same slot and grammar as route 1) XOR a load_tasks override — a self-seeding topology passed the flag is refused rather than silently ignoring it. The seed taskset's shared tool servers (Taskset.tools) are served once by TopologyRunner.serving, mirroring Environment.serving.
  • Artifact: every trace carries agent / parents / trainable; a topology run persists one AgentGraph per traces.jsonl line (traces nested, completion order). The links live on the traces, so the DAG is recoverable from a flat dump.
  • Config: agents are typed AgentConfig fields (field name = agent name) — everything is CLI/TOML-addressable with typed help (--topology.black.model, --topology.judge.harness.runtime.type docker). Pins live on AgentConfig subclasses (vf.DirectAgentConfig / vf.NullAgentConfig shared); partial overrides deep-merge into a pin, an explicit harness.id swaps it, and a topology can lock a slot (llm-judge's judge) with a refusal that points at the alternative.

The instantiations (all verified live, deepseek/deepseek-v4-flash)

id shape emblematic of live result
llm-judge (built-in) solver → judge from_trace + a locked agent slot; judge fixed to the in-process direct harness (episode ≈ one API call) 10/10 agreement w/ gsm8k's verifier; 8/8 w/ wiki-search's own judge; verdicts compose with task rewards
agentic-judge (built-in) solver → judge-as-real-agent task setup uploads the solver's entire serialized trace into the judge's runtime; harness + prompt configurable judge investigated over 3 bash turns, SCORE: 10; also ran on harbor in a prime sandbox
proposer-solver-v1 proposer → n solvers dynamic task generation: MCP submit_question tool → typed state → SolverTask.from_trace; asymmetric capability (proposer on null with tools, solvers on tool-less direct); difficulty peaked at 50% solve rate 4/4 solvers correct → difficulty 0.0 (too-easy punished); broken-code submissions eat well_formed 0
writer-editors-v1 writer → n editors → writer, × rounds fan-out + fan-in (parents=[draft, *edits]); one vf.Judge call per instance puts the same reward on every trace 6 briefs, exact shape, identical improvement across each instance
chess-v1 two live sessions back-and-forth: host-side chess.Board as referee, illegal-move feedback as ordinary turns, forfeit via session.end(), per-seat model routing full 8-ply games (incl. Ruy Lopez theory), zero illegal moves, adjudication correct
debate-v1 n concurrent sessions N-ary sessions of ONE agent config: rounds gathered N-wide, per-seat views, peer voting — the interaction scores itself all ballots valid, vote-share rewards per seat
shared-runtime-v1 writer + reader, one box provision() + borrowed runtime=: two agents' rollouts share one world; the run never starts/stops a borrowed box both traces stamped borrowed=True on the same box, handoff verified in-runtime
uv run eval --topology.id llm-judge --topology.taskset.id gsm8k-v1 -n 4
uv run eval --topology.id chess-v1 -n 1 --max-turns 64 --timeout.rollout 900 --sampling.max-tokens 4096 \
  --topology.black.model openai/gpt-5.4-mini   # mixed-model self-play, one flag
uv run eval --topology.id proposer-solver-v1 -n 1 -r 1
uv run eval --topology.id debate-v1 -n 1 --max-turns 8

Framework deltas outside the new layer (small, deliberate)

  • Rollout gains runtime= (borrowed boxes; never started/stopped by the run) and user= (the session seat, pre-empting Task.user).
  • RolloutSession.refused() honors a pre-set stop condition ("a stopped trace takes no more turns" — what Session.end() rides on).
  • Trace gains the agent-graph links: agent, parents, trainable.
  • @reward/@metric gain the topology agent= scope.
  • env.py: the inline stage-timeout block is extracted as resolve_stage_timeouts, and validate_pairing factors through a task-class-level validate_task_pairing — both shared by Environment and topology agents instead of duplicated.
  • Chat dialect extend coerces reasoning-only null-content assistant turns to "" (upstreams 422 them on re-send — found by a live chess game; regression-tested).
  • The null program's MCP connect is bounded (60s) — a wedged handshake was a silent 0-turn harness_timeout.
  • The judge plugin tier is untouched: config.judges (in-episode verdicts), vf.Judge (verdicts in your reward code), and the judge topologies (verdicts as their own agent episodes) are three distinct tiers by where the verdict lives, and they compose.

Testing

95 unit tests, no keys needed: config narrowing + pin semantics + locked slots + seed XOR + scope validation; instance links, deferred rewards, go-failure capture, graph record round-trip; session handshake/poison/double-drive/cancel-desync semantics and every interact() refusal; scripted-session tests driving chess (fool's mate through the real board) and debate (vote tallying) without a model; borrowed-runtime lifetime. Key-gated e2e runs all seven topologies live. Everything above was additionally smoke-run live on the merged contract.

Out of scope (deliberately)

Trainer wire (env-server run_topology, grouping semantics), --server / --resume / --rich under topologies, agent-as-tool (consultation inside a single turn — composes on sessions later).

🤖 Generated with Claude Code

eligotts and others added 2 commits July 7, 2026 05:29
…erred cross-agent rewards

A Topology composes episodes (one agent x one task -> one trace) into a
multi-agent interaction: plain imperative control flow in go() (loops =
rounds, gather = fan-out, awaiting = fan-in), trace->task forward arrows,
and judgement declared as @vf.reward(agent=...)/@vf.metric(agent=...)
methods run over the finished instance. Each instance persists as one
AgentGraph record (parent-linked traces, nested).

Core: task-first contract (all episode behavior on the Task class;
Taskset = pure factory), Agent = harness + routing only (per-agent
model/client/sampling/trainable), pinned-harness AgentConfig subclassing
with deep-merged partial overrides, seed slot XOR with load_tasks
overrides, TopologyRunner as the run-lifetime resolver, in-process
`direct` harness, judge plugin tier deleted in favor of the vf.Judge
single-call utility + judge-as-agent topologies.

Ships four instantiations, all verified live (deepseek/deepseek-v4-flash):
- llm-judge (built-in): any taskset, solver + fixed direct-harness judge
- agentic-judge (built-in): full solver trace uploaded into a real judge
  agent's runtime (verified on harbor in a prime sandbox)
- proposer-solver-v1: proposer invents code-checkable puzzles via a
  submit tool; tool-less direct solvers race them; difficulty reward
- writer-editors-v1: rounds + fan-in; one vf.Judge verdict rewards
  every trace

Hardening from the live runs: markdown-tolerant verdict parsing, PEP 723
header for model-submitted scripts, bounded MCP connect in the null
program (was a silent 0-turn hang), retried-away errors parked in
trace.info, shared tool servers under topologies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore main's agent-guidance docs (AGENTS.md, assets/agents, assets/lab
root) and the eval dashboard pager (our branch had clobbered #1912's
origin-anchored paging); drop a stray scratch file. environments/AGENTS.md
and assets/lab/environments/AGENTS.md remain regenerated because main's
checked-in copies are stale relative to their source (docs/environments.md
lost the "v1 Env Shape" section; scripts/sync.py output enforced by
pre-commit). API docs (GUIDE/README/ARCHITECTURE) stay updated to match
the shipped contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	verifiers/v1/GUIDE.md
#	verifiers/v1/cli/output.py
#	verifiers/v1/cli/validate.py
#	verifiers/v1/judge.py
#	verifiers/v1/taskset.py
Comment thread verifiers/v1/task.py Outdated
if len(rewards) < 2
else await asyncio.gather(*(invoke(fn, available) for fn in rewards))
)
for fn, scores in zip(rewards, results):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/task.py:232

Task.score_group() zips traces with each group reward's scores without checking that the two lists have the same length. When a @group_reward returns fewer scores than traces, the trailing traces silently get no reward from that function; when it returns too many, the extra scores are silently discarded. This produces incorrect rewards without any error, so the mismatch is easy to miss. Consider asserting len(scores) == len(traces) before the zip and raising a TaskError on mismatch.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/task.py around line 232:

`Task.score_group()` zips `traces` with each group reward's `scores` without checking that the two lists have the same length. When a `@group_reward` returns fewer scores than traces, the trailing traces silently get no reward from that function; when it returns too many, the extra scores are silently discarded. This produces incorrect rewards without any error, so the mismatch is easy to miss. Consider asserting `len(scores) == len(traces)` before the zip and raising a `TaskError` on mismatch.

if trace.stop_condition is not None:
break
raise
message = completion.choices[0].message

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium direct/harness.py:67

When the interception server injects hidden user-simulator turns, DirectHarness.launch drops that conversation history before the tool-call bounce retry. The loop only appends completion.choices[0].message to its local messages list, so on the next iteration the model answers against an incomplete prompt — missing the user-sim turns the server already added — and produces incorrect recovery responses. Consider reconstructing messages from the full trace (or the server's response) rather than only the final assistant message, so the bounce retry preserves the prior conversation context.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/direct/harness.py around line 67:

When the interception server injects hidden user-simulator turns, `DirectHarness.launch` drops that conversation history before the tool-call bounce retry. The loop only appends `completion.choices[0].message` to its local `messages` list, so on the next iteration the model answers against an incomplete prompt — missing the user-sim turns the server already added — and produces incorrect recovery responses. Consider reconstructing `messages` from the full trace (or the server's response) rather than only the final assistant message, so the bounce retry preserves the prior conversation context.

Comment thread verifiers/v1/topology.py
TopologyError, f"topology {self.topology.config.id!r} scoring"
):
await self.topology.score(run.graph)
except TopologyError as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/topology.py:566

When a @reward or @metric method in Topology.score raises, run_instance catches the TopologyError and records graph.error, but traces that were already mutated by earlier scoring methods keep their partial rewards and metrics. The persisted AgentGraph for a failed instance ends up with a silently incomplete, inconsistent set of topology scores that downstream code can mistake for complete judgement.

This happens because score mutates traces in place as it iterates, and run_instance has no rollback of those mutations when a later scoring method crashes. Consider either scoring into a side structure and committing only after all methods succeed, or clearing partial topology rewards/metrics on the traces when the TopologyError is caught.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topology.py around line 566:

When a `@reward` or `@metric` method in `Topology.score` raises, `run_instance` catches the `TopologyError` and records `graph.error`, but traces that were already mutated by earlier scoring methods keep their partial rewards and metrics. The persisted `AgentGraph` for a failed instance ends up with a silently incomplete, inconsistent set of topology scores that downstream code can mistake for complete judgement.

This happens because `score` mutates traces in place as it iterates, and `run_instance` has no rollback of those mutations when a later scoring method crashes. Consider either scoring into a side structure and committing only after all methods succeed, or clearing partial topology rewards/metrics on the traces when the `TopologyError` is caught.

taskset = vf.load_taskset(config.taskset)
# `WireTask` reads any taskset's saved task without a runtime or its Task type (see WireTask).
traces = read_traces(source, Trace[WireTask, state_cls(type(taskset))])
task_cls = vf.task_type(config.taskset.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium cli/replay.py:62

--taskset.* overrides passed to replay are silently ignored: the run re-scores with the original task deserialized from results.jsonl instead of the taskset built from the (possibly overridden) config. A replay with changed judge settings produces the old scoring, so users believe their overrides took effect when they didn't. This happens because run_replay now calls await trace.task.score(trace) on the deserialized trace.task, bypassing vf.load_taskset(config.taskset) entirely. Consider scoring through a taskset built from config.taskset so config overrides apply, or document that replay ignores --taskset.* overrides if that is intentional.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/cli/replay.py around line 62:

`--taskset.*` overrides passed to `replay` are silently ignored: the run re-scores with the original task deserialized from `results.jsonl` instead of the taskset built from the (possibly overridden) config. A replay with changed judge settings produces the old scoring, so users believe their overrides took effect when they didn't. This happens because `run_replay` now calls `await trace.task.score(trace)` on the deserialized `trace.task`, bypassing `vf.load_taskset(config.taskset)` entirely. Consider scoring through a taskset built from `config.taskset` so config overrides apply, or document that replay ignores `--taskset.*` overrides if that is intentional.

Comment thread verifiers/v1/topology.py
Comment on lines +292 to +294
raise ValueError(
f"topology @{kind} {fn.__name__!r} declares no agent scope; "
f"topology judgement is per-agent — use @vf.{kind}(agent=...)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/topology.py:292

A subclass that overrides an inherited @vf.reward/@vf.metric method with an undecorated replacement fails at load time with a spurious scope error for the base-class method — even though score() would never run it. The validation in agents iterates every class in type(self).__mro__ and every entry in vars(klass), so it processes the base class's decorated method without checking whether a subclass overrides that name. discover_decorated, used by score(), follows normal MRO override semantics and would skip the shadowed base method. Consider skipping names overridden by a subclass during validation.

+        seen = set()
         for klass in type(self).__mro__:
             for fn in vars(klass).values():
+                name = getattr(fn, "__name__", None) or getattr(getattr(fn, "__func__", None), "__name__", None)
+                if name in seen:
+                    continue
+                if name is not None:
+                    seen.add(name)
                 fn = getattr(fn, "__func__", fn)
Also found in 1 other location(s)

verifiers/v1/env.py:201

validate_pairing() treats any override of Task.load_tools or Task.load_user as meaning every instance of that class requires MCP or a user simulator. Because these methods are instance methods and may legitimately return []/None based on task data, Environment.__init__ can now reject an otherwise valid eval before any tasks are loaded. A task class with conditional tools/user support will raise here even when the actual tasks in this run do not use those features.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topology.py around lines 292-294:

A subclass that overrides an inherited `@vf.reward`/`@vf.metric` method with an undecorated replacement fails at load time with a spurious scope error for the base-class method — even though `score()` would never run it. The validation in `agents` iterates every class in `type(self).__mro__` and every entry in `vars(klass)`, so it processes the base class's decorated method without checking whether a subclass overrides that name. `discover_decorated`, used by `score()`, follows normal MRO override semantics and would skip the shadowed base method. Consider skipping names overridden by a subclass during validation.

Also found in 1 other location(s):
- verifiers/v1/env.py:201 -- `validate_pairing()` treats any override of `Task.load_tools` or `Task.load_user` as meaning *every* instance of that class requires MCP or a user simulator. Because these methods are instance methods and may legitimately return `[]`/`None` based on task data, `Environment.__init__` can now reject an otherwise valid eval before any tasks are loaded. A task class with conditional tools/user support will raise here even when the actual tasks in this run do not use those features.

Comment thread verifiers/v1/agent.py
Comment thread verifiers/v1/rollout.py Outdated
Comment thread verifiers/v1/agent.py
)
if retry is not None:
return await run_with_retry(attempt, retry)
return await attempt.run()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/agent.py:208

When Agent.run is called with both a borrowed runtime and a retry config, each retry attempt reuses the same runtime instance without resetting its state. A failed attempt can leave files, processes, or other side effects behind, so subsequent retries execute against contaminated state rather than starting fresh. This means run_with_retry can produce a passing trace that reflects leftover state from a previous failed attempt, or fail again for the same reason, instead of giving each retry a clean environment.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/agent.py around line 208:

When `Agent.run` is called with both a borrowed `runtime` and a `retry` config, each retry attempt reuses the same runtime instance without resetting its state. A failed attempt can leave files, processes, or other side effects behind, so subsequent retries execute against contaminated state rather than starting fresh. This means `run_with_retry` can produce a passing trace that reflects leftover state from a previous failed attempt, or fail again for the same reason, instead of giving each retry a clean environment.

Comment thread verifiers/v1/services.py
Comment thread verifiers/v1/topology.py
the config's `taskset` slot (`--topology.taskset.id <id>`); override for a
topology that constructs its own seeds."""
if not self.config.taskset.id:
raise ValueError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/topology.py:329

A @staticmethod decorated @vf.reward(agent=...) or @vf.metric(agent=...) method passes the load-time validation in Topology.agents but is silently skipped during Topology.score: the traces it should have scored persist without the declared metric/reward, producing incorrect training/eval results with no error. score discovers methods via discover_decorated, which filters by inspect.ismethod, and a @staticmethod on an instance is a plain function, not a bound method — so it is filtered out. The validation in agents unwraps __func__ and catches static methods, but score does not apply the same unwrapping, so the two paths disagree. Consider making discover_decorated (or its call in score) unwrap __func__ the same way the validation does.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topology.py around line 329:

A `@staticmethod` decorated `@vf.reward(agent=...)` or `@vf.metric(agent=...)` method passes the load-time validation in `Topology.agents` but is silently skipped during `Topology.score`: the traces it should have scored persist without the declared metric/reward, producing incorrect training/eval results with no error. `score` discovers methods via `discover_decorated`, which filters by `inspect.ismethod`, and a `@staticmethod` on an instance is a plain function, not a bound method — so it is filtered out. The validation in `agents` unwraps `__func__` and catches static methods, but `score` does not apply the same unwrapping, so the two paths disagree. Consider making `discover_decorated` (or its call in `score`) unwrap `__func__` the same way the validation does.

Comment thread verifiers/v1/topology.py
Comment thread verifiers/v1/agent.py Outdated
) -> Trace:
"""Run this agent on `task` once and return its trace."""
ctx = ctx or self.ctx
services = services if services is not None else self._services

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/agent.py:192

Calling Agent.run(task) without entering the agent via async with or passing services= leaves services as None, so any task that declares a shared tool server raises "task declares a shared tool server but no shared-server registry is serving" inside Rollout._shared_urls(). This breaks the documented standalone run(task) path: services is only populated from self._services, which stays None unless the caller separately entered the agent or supplied services. Consider provisioning a RunServices scope inside run() when none is available, or documenting that the caller must enter the agent context before calling run() for shared-server tasks.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/agent.py around line 192:

Calling `Agent.run(task)` without entering the agent via `async with` or passing `services=` leaves `services` as `None`, so any task that declares a shared tool server raises `"task declares a shared tool server but no shared-server registry is serving"` inside `Rollout._shared_urls()`. This breaks the documented standalone `run(task)` path: `services` is only populated from `self._services`, which stays `None` unless the caller separately entered the agent or supplied services. Consider provisioning a `RunServices` scope inside `run()` when none is available, or documenting that the caller must enter the agent context before calling `run()` for shared-server tasks.

Comment thread environments/shared_runtime_v1/shared_runtime_v1/topology.py Outdated
…ns, close test gaps

The Agent primitive stays public but sheds its duplicated ownership half:
RunServices is the ONE context manager that owns pooled interception +
shared MCP servers, and an Agent only ever borrows them (ctor-injected).
Deleted: Agent.__aenter__/__aexit__/_owned_services/multiplex, the
per-call run(services=)/run(ctx=) params, and the speculative public
rollout() (folded into the retry attempt). A bare Agent(harness, ctx)
.run(task) remains fully standalone via per-rollout interception —
verified live.

Supporting cleanups:
- Rollout takes the one resolved `timeouts: TimeoutConfig` instead of
  four kwargs (env.py and agent.py stop unpacking by hand).
- retries.run_with_retry is typed against a Runnable protocol, so the
  agent attempt no longer duck-types where Rollout was hinted.
- The `Agent as ExecutableAgent` import alias is gone; TopologyAgent
  drops its dead ctx= plumbing and provision() gains its docstring and
  return annotation.
- vf.DirectAgentConfig / vf.NullAgentConfig replace three identical
  per-example pin subclasses; llm_judge exposes the one SCORE-line
  parser (writer-editors' copy deleted); vf.RunServices exported.
- shared-runtime example: defensive handoff read (a failed writer
  episode early-returns instead of KeyError-ing go), docstring is
  explicit that the rewards close over the runtime handoff, not the
  model replies.

Test gaps closed: topology-level provision()/runtime= borrowing (fake
runtime lifetime + str-arm parents), the shared-runtime handoff stubbed
end-to-end, proposer-solver's happy-path fan-out (solve_rate 0.5 ->
difficulty 1.0), and a key-gated live e2e for shared-runtime-v1.
53 unit tests pass; llm-judge, shared-runtime, and a bare-Agent script
smoked live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/agent.py
}

@contextlib.asynccontextmanager
async def provision(self, task: Task | None = None) -> AsyncIterator[Runtime]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium v1/agent.py:211

When Agent.run() is called with a borrowed runtime=, runtime_for() returns runtime.config directly (line 147) and skips resolve_runtime_config(...), so task-specific overrides for image, workdir, and resources are silently dropped. A task that requires a specific container image can run inside an unrelated borrowed runtime with no error. Consider still resolving task-specific settings against the borrowed runtime's config, or documenting that borrowed runtimes intentionally ignore task requirements.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/agent.py around line 211:

When `Agent.run()` is called with a borrowed `runtime=`, `runtime_for()` returns `runtime.config` directly (line 147) and skips `resolve_runtime_config(...)`, so task-specific overrides for `image`, `workdir`, and `resources` are silently dropped. A task that requires a specific container image can run inside an unrelated borrowed runtime with no error. Consider still resolving task-specific settings against the borrowed runtime's config, or documenting that borrowed runtimes intentionally ignore task requirements.

stripped = line.strip().strip("*`").strip()
if not stripped.upper().startswith("SCORE:"):
continue
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium topologies/llm_judge.py:71

parse_score returns nan for a SCORE: nan line — float('nan') succeeds, and min/max leave NaN untouched, so the clamp at the end doesn't catch it. The caller (JudgeTask.committed) treats this as a committed verdict and judge() records a NaN reward, poisoning trace.rewards/trace.reward and downstream aggregations. Consider rejecting non-finite values (e.g. math.isnan) and returning None.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topologies/llm_judge.py around line 71:

`parse_score` returns `nan` for a `SCORE: nan` line — `float('nan')` succeeds, and `min`/`max` leave NaN untouched, so the clamp at the end doesn't catch it. The caller (`JudgeTask.committed`) treats this as a committed verdict and `judge()` records a NaN reward, poisoning `trace.rewards`/`trace.reward` and downstream aggregations. Consider rejecting non-finite values (e.g. `math.isnan`) and returning `None`.

`run(task)` composes completed episodes; back-and-forth interaction (chess,
negotiation, debate — each agent effectively the other's user) needs episodes
held OPEN. `Agent.interact(task)` yields a `Session`: the episode runs in the
background, suspending between turns, and `go` converses with it. Three
members, deliberately: `turn(message) -> reply`, `end()` (idempotent; scope
exit calls it), `.trace` (live handle). N sessions + imperative routing in
`go` compose into round-robins, simultaneous moves (asyncio.gather over
turns), moderated rooms, and hidden-information views — no scheduler, no
message bus, no ordering DSL.

Mechanically a session is a user simulator without the server: the existing
interception user loop suspends the episode between turns awaiting the user
seat's `Respond`; a session supplies that callable over an in-process queue
handshake (Rollout gains `user=`), so the turn loop, budgets, @Stops, trace
building, and graph land unchanged. The safety contract is loud, not patient:
`turn()` on a dead episode raises `SessionEnded` (episode failure stays data —
`go` decides what a dead seat means), a second in-flight `turn()` is refused,
and a prompted task / a task with its own `load_user` / a harness that can't
take injected user turns are refused at the `interact()` call. No `retry=` —
half-played interactions can't be re-run. `refused()` now also honors a
pre-set stop condition (a stopped trace takes no more turns), which is all
`end()` needs. Scripted counterparts stay `vf.User` sims; sessions are for
counterparts that are agents.

Examples: `chess-v1` (two seats, host-side `chess.Board` as referee, illegal-
move feedback turns, forfeit via `session.end()`; verified live — 8 plies of
Ruy Lopez theory, adjudicated draw) and `debate-v1` (4 concurrent seats of one
agent config: openings, rebuttals, and peer votes gathered N-wide; verified
live — all ballots valid, vote-share rewards landed per seat). Each seat's
trace is ONE multi-turn trajectory with its counterparts as user turns — the
self-play training-sample shape, not one episode per move.

Also fixes a live-run discovery in the chat dialect: a reasoning-only
assistant turn (null content, no tool calls) is valid output but was rejected
on re-send by upstreams ("content is required unless tool_calls"), killing
simulated conversations mid-game; `extend` now coerces it to the empty string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread environments/chess_v1/chess_v1/topology.py
Comment thread environments/chess_v1/chess_v1/topology.py
eligotts and others added 2 commits July 10, 2026 06:41
…ygiene

Final comb over the sessions work:

- A `turn()` cancelled mid-flight (a sibling seat in a gather raising) leaves
  its message with the model; the eventual reply would land as queue residue
  and a later `turn()` would consume it one turn late. `_pending` now stays
  set through cancellation, so the next `turn()` refuses loudly — `end()` is
  the sanctioned way out of a desynced seat. Tested.
- Regression test for the chat-dialect `extend` coercion (truncated
  reasoning turn -> null content -> upstream 422 on re-send), including the
  tool-call turn staying legitimately content-less.
- The chess e2e/docs carried the exact 512-token cap that caused the live
  truncation forfeit: bumped to reasoning headroom (4096; debate 2048) and
  the example commands now advertise --sampling.max-tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opology layer

Main wins the core: TaskData/Task split (frozen wire half + plain behavior
class), declarative Task.tools/Task.user ClassVars with config pairing,
Taskset.load(), TraceTask provenance on traces, taskset-scoped shared tool
servers (serve_shared), the restored-and-deepened judge plugin tier
(per-task config.judges run inside task.score — our earlier deletion is
dropped in favor of main's landed direction), traces.jsonl, --push, node
timestamps, runtime info on traces.

Re-applied on main's shapes, surgically:
- Rollout regains `runtime=` (borrowed boxes: provision/interact share a
  world; a borrowed box is never started or stopped by the run) and
  `user=` (the programmatic user seat sessions plug into serve_user).
- refused() honors a pre-set stop; the chat dialect's extend still coerces
  reasoning-only null-content turns; the null program's MCP connect stays
  bounded at 60s.
- Trace regains the agent-graph links (agent/parents/trainable).
- reward/metric decorators regain the topology `agent=` scope.
- env.py: the inline stage-timeout block is re-extracted as
  resolve_stage_timeouts and validate_pairing factors through a new
  task-class-level validate_task_pairing — both shared by Environment and
  topology agents instead of duplicated.

The multi-agent layer (Agent/Session/interact, Topology/TopologyRunner/
AgentGraph, llm-judge, agentic-judge, and the five example envs + fixtures
+ tests) is ported wholesale to the new contract: Data/Task pairs
everywhere, task.data.* reads, tools as ClassVars (proposer's submit
toolset needed zero changes), RunServices slims to interception pools
only, and TopologyRunner.serving serves the seed taskset's shared tools
once, mirroring Environment.serving — the lazy registry is gone because
shared tools are taskset-scoped and the seed taskset is known up front.
Docs follow main's consolidation: v1 GUIDE/README/ARCHITECTURE retire and
the topology + sessions guide moves to docs/v1/topologies.md (mint nav).

95 tests pass; llm-judge, chess (full 8-ply game, adjudicated draw), and
debate (3 seats, valid ballots) verified live on the merged contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
messages.append({"role": "user", "content": prompt})
elif prompt is not None:
messages.extend(message_to_wire(m) for m in prompt)
client = AsyncOpenAI(base_url=endpoint, api_key=secret)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High direct/harness.py:51

AsyncOpenAI is created with max_retries left at the SDK default, so a transient transport failure on POST /chat/completions triggers an automatic retry that re-samples the model and appends a duplicate turn to messages. This forks the recorded rollout into an extra trajectory instead of preserving a single one. Set max_retries=0 when constructing the client so the harness controls retry behavior rather than the SDK.

Suggested change
client = AsyncOpenAI(base_url=endpoint, api_key=secret)
client = AsyncOpenAI(base_url=endpoint, api_key=secret, max_retries=0)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/direct/harness.py around line 51:

`AsyncOpenAI` is created with `max_retries` left at the SDK default, so a transient transport failure on `POST /chat/completions` triggers an automatic retry that re-samples the model and appends a duplicate turn to `messages`. This forks the recorded rollout into an extra trajectory instead of preserving a single one. Set `max_retries=0` when constructing the client so the harness controls retry behavior rather than the SDK.

return None


class JudgeTask(Task[DataT]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium topologies/llm_judge.py:79

JudgeTask.for_attempt renders the judge's <task> section from task.data.prompt_text, but TaskData.prompt can be None for user-simulator tasks where the task framing is only established conversationally. For those tasks the judge prompt contains an empty <task> block, so the judge scores an attempt without ever seeing what problem was being solved — producing incorrect rewards for any user-driven taskset. Consider falling back to the full conversation text (or the trace's messages) when prompt_text is empty, so the judge always sees the task framing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topologies/llm_judge.py around line 79:

`JudgeTask.for_attempt` renders the judge's `<task>` section from `task.data.prompt_text`, but `TaskData.prompt` can be `None` for user-simulator tasks where the task framing is only established conversationally. For those tasks the judge prompt contains an empty `<task>` block, so the judge scores an attempt without ever seeing what problem was being solved — producing incorrect rewards for any user-driven taskset. Consider falling back to the full conversation text (or the trace's messages) when `prompt_text` is empty, so the judge always sees the task framing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

if config.push and not rich:

When a topology eval runs with --push, push_traces(traces, config) derives the environment/dataset name from config.taskset.id or config.id, but EvalConfig.check_topology() rejects both of those fields when config.topology is set. As a result, pushed topology results upload with an empty environment/dataset name instead of config.topology.id, producing incorrect metadata or failing to resolve the environment. Consider deriving the topology upload name from config.topology.id (e.g., by short-circuiting push_traces before the taskset/id lookup), or document the intended push semantics for topology runs if this is by design.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/cli/eval/main.py around line 107:

When a topology eval runs with `--push`, `push_traces(traces, config)` derives the environment/dataset name from `config.taskset.id or config.id`, but `EvalConfig.check_topology()` rejects both of those fields when `config.topology` is set. As a result, pushed topology results upload with an empty environment/dataset name instead of `config.topology.id`, producing incorrect metadata or failing to resolve the environment. Consider deriving the topology upload name from `config.topology.id` (e.g., by short-circuiting `push_traces` before the taskset/id lookup), or document the intended push semantics for topology runs if this is by design.

]
# Opening statements — N suspended episodes generating concurrently.
statements = list(
await asyncio.gather(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium debate_v1/topology.py:117

A single debater's seat.turn(...) raising SessionEnded (timeout, stop condition, or episode death) aborts the entire debate — asyncio.gather propagates the exception, unwinding the AsyncExitStack and converting the whole topology run into a TopologyError instead of recording that one seat's failure as data. This happens at all three gather sites (lines 117, 130, 149) because gather is called without return_exceptions=True and there is no try/except around the turn calls. Consider passing return_exceptions=True and handling SessionEnded per-seat so a failed debater is recorded in trace.info rather than crashing the debate.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @environments/debate_v1/debate_v1/topology.py around line 117:

A single debater's `seat.turn(...)` raising `SessionEnded` (timeout, stop condition, or episode death) aborts the entire debate — `asyncio.gather` propagates the exception, unwinding the `AsyncExitStack` and converting the whole topology run into a `TopologyError` instead of recording that one seat's failure as data. This happens at all three gather sites (lines 117, 130, 149) because `gather` is called without `return_exceptions=True` and there is no `try`/`except` around the turn calls. Consider passing `return_exceptions=True` and handling `SessionEnded` per-seat so a failed debater is recorded in `trace.info` rather than crashing the debate.

return cls(
AgenticJudgeData(
idx=task.data.idx,
prompt=prompt.format(path=TRACE_PATH),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium topologies/agentic_judge.py:69

for_trace calls prompt.format(path=TRACE_PATH) on the user-configurable --topology.prompt string, so any custom rubric that contains other braces — e.g. a JSON snippet like { "score": 10 } or a placeholder like {answer} — raises KeyError/ValueError and crashes the agentic-judge run before the judge rollout starts. Only the {path} placeholder should be substituted. Consider replacing the literal with a targeted replacement like prompt.replace("{path}", TRACE_PATH).

Suggested change
prompt=prompt.format(path=TRACE_PATH),
prompt=prompt.replace("{path}", TRACE_PATH),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/topologies/agentic_judge.py around line 69:

`for_trace` calls `prompt.format(path=TRACE_PATH)` on the user-configurable `--topology.prompt` string, so any custom rubric that contains other braces — e.g. a JSON snippet like `{ "score": 10 }` or a placeholder like `{answer}` — raises `KeyError`/`ValueError` and crashes the `agentic-judge` run before the judge rollout starts. Only the `{path}` placeholder should be substituted. Consider replacing the literal with a targeted replacement like `prompt.replace("{path}", TRACE_PATH)`.

f.write(data + b"\n")


def write_graph(results_dir: Path, graph: AgentGraph) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium cli/output.py:83

write_graph() writes AgentGraph records (via graph.to_record()) into traces.jsonl, but read_traces() unconditionally parses every line as a Trace via TypeAdapter(trace_type). When replay reads a topology run's traces.jsonl, the first AgentGraph record fails validation, so replay <topology-output-dir> errors instead of replaying the saved run. The read path needs to handle graph records (e.g., detect or branch on the record type) rather than always deserializing as Trace.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/cli/output.py around line 83:

`write_graph()` writes `AgentGraph` records (via `graph.to_record()`) into `traces.jsonl`, but `read_traces()` unconditionally parses every line as a `Trace` via `TypeAdapter(trace_type)`. When `replay` reads a topology run's `traces.jsonl`, the first `AgentGraph` record fails validation, so `replay <topology-output-dir>` errors instead of replaying the saved run. The read path needs to handle graph records (e.g., detect or branch on the record type) rather than always deserializing as `Trace`.

eligotts and others added 2 commits July 10, 2026 21:29
Main's task module documents `MyTask.from_trace(trace)` as the opt-in
constructor for a task derived from a finished rollout — exactly the
forward-arrow classmethods our judge topologies already carried as
`for_attempt`/`for_trace`, with one redundancy: they took the seed task
as a separate argument when `trace.task.data` already carries it (the
trace is self-describing; that's what TraceTask provenance is for).
Renamed to `from_trace(trace, ...)` and the inputs read off the trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-merge audit: all example code was already on the new contract
(Data/Task pairs, task.data reads, declarative tools, state generics) —
the stragglers were prose. Fixed: proposer-solver's docstring still said
load_tools/results.jsonl, topology.py's graph docstrings said
results.jsonl, and docs/v1/topologies.md's session snippet showed the
pre-split SeatTask construction and the old load_user refusal wording.

One idiom upgrade: proposer-solver's SolverTask now demonstrates
`Task.from_trace` — it was the textbook case (question, ground-truth
code, and input all come off the proposer trace's info["submission"]).
Writer-editors' critique tasks deliberately stay inline: in round 2+ the
upstream trace is a ReviseTask whose data carries no brief, so the trace
alone isn't self-describing there — from_trace only where it's honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants