Skip to content

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

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

feat(v1): multi-agent topologies — imperative go(), agent graphs, deferred cross-agent rewards#1941
eligotts wants to merge 25 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 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.

Comment thread verifiers/v1/cli/replay.py
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)`.

Comment thread verifiers/v1/cli/output.py
eligotts and others added 3 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>
Comment thread verifiers/v1/topology.py
limits=limits,
timeout=self.config.timeout,
services=services,
shared_tools=self._shared_tools,

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 v1/topology.py:685

_agents_for passes the taskset-wide _shared_tools to every agent, including agents that consume derived tasks. Agent._validate_pairing treats nonempty shared_tools as tools exposed by every task, so a tool-less harness like direct (SUPPORTS_MCP = False) is rejected even when its derived task has no tools. An llm-judge topology over a tool-bearing taskset runs the solver but fails when the direct judge starts. The shared tools should be scoped to the agent(s) that actually consume the seed task, not passed to every agent unconditionally.

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

`_agents_for` passes the taskset-wide `_shared_tools` to every agent, including agents that consume derived tasks. `Agent._validate_pairing` treats nonempty `shared_tools` as tools exposed by every task, so a tool-less harness like `direct` (`SUPPORTS_MCP = False`) is rejected even when its derived task has no tools. An `llm-judge` topology over a tool-bearing taskset runs the solver but fails when the `direct` judge starts. The shared tools should be scoped to the agent(s) that actually consume the seed task, not passed to every agent unconditionally.

Comment thread verifiers/v1/topology.py
eligotts and others added 2 commits July 11, 2026 03:19
8e676a4 arrived formatted at ~120 columns (a different ruff config);
re-run ruff format so the tree matches this repo's formatter again.
No semantic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The topology field was declared twice downstream (EvalConfig, ServeConfig)
while everything that consumed it received an EnvConfig without it — hence
a getattr side-channel in resolve_topology_runner and push, a special-cased
'topology' key in env_config_data, a hand-rolled _configs_from_data in the
worker spawn path, and a separate topology_config parameter on EnvServer.

Hoist the field (plus its narrowing and XOR validators) onto EnvConfig.
env.py can't import topology.py (the executor half imports env), so the
annotation is a forward reference finalized by model_rebuild at the bottom
of topology.py — any import of verifiers.v1 completes it before user code
runs. env_config_data strips the manufactured taskset/harness defaults
under a topology, mirroring write_config, so worker-side revalidation
doesn't trip the harness-with-topology guard.

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

Resume's keep/re-run rule guessed at a question only the topology can
answer: is a persisted graph with an errored nested trace a failed
invocation (single-agent lowering: yes — the one trace IS the invocation)
or a valid result whose go already tolerated the failure (proposer-solver:
an errored solver is excluded from solve_rate's denominator, yet the whole
scored instance was being discarded and re-run on every --resume)?

Make it a declared verdict: Topology.complete(graph) -> bool, defaulting
to the conservative rule (no instance-level error, no errored trace) via
module-level graph_complete — which server-mode resume applies directly,
having no live topology. resume.load takes the predicate and judges loaded
AgentGraphs instead of poking raw dict keys. proposer-solver-v1 overrides
it as the exemplar. Read-only by contract: what a failed child means stays
in go and the declared rewards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
keep: list[bytes] = []
if idx not in selected or len(good[idx]) >= num_rollouts:
continue
graph = AgentGraph.load(row)

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 eval/resume.py:79

AgentGraph.load(row) silently accepts legacy flat trace rows (which have id, task, and error fields but no traces key), because the overlapping fields satisfy the model and traces defaults to []. When resuming an interrupted eval directory written by the previous implementation, these flat rows are treated as complete invocations with empty trace lists. As a result, prior successful traces are dropped from the returned graphs, dashboard state, and the final push_traces upload; when all rows are flat the command falsely reports every invocation complete while returning empty graphs. Consider detecting flat rows (e.g., rows lacking a traces key) and wrapping them into a single-trace graph, or rejecting/migrating the old format before counting a row as complete.

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

`AgentGraph.load(row)` silently accepts legacy flat trace rows (which have `id`, `task`, and `error` fields but no `traces` key), because the overlapping fields satisfy the model and `traces` defaults to `[]`. When resuming an interrupted eval directory written by the previous implementation, these flat rows are treated as complete invocations with empty trace lists. As a result, prior successful traces are dropped from the returned `graphs`, dashboard state, and the final `push_traces` upload; when all rows are flat the command falsely reports every invocation complete while returning empty graphs. Consider detecting flat rows (e.g., rows lacking a `traces` key) and wrapping them into a single-trace graph, or rejecting/migrating the old format before counting a row as complete.

eligotts and others added 5 commits July 11, 2026 07:16
… the resolved runtime

Three correctness holes vs the agent-programs work (#1939), two of them ours:

- The dead-borrow tombstone guard existed pre-merge and was silently lost
  adopting main's rollout.py — Runtime.stopped's docstring promised a refusal
  nothing enforced. Restored: borrowing a stopped box raises at the caller.
- An owner tearing a borrowed box down under an in-flight run surfaced as an
  opaque harness error captured onto the trace — a lifetime bug misattributed
  as world failure (and retryable). Ported #1939's re-attribution: same bug,
  same channel — raised to the caller with the raw failure chained.
- NEEDS_CONTAINER was checked against harness.config.runtime, not where the
  run actually lands: a container-needing task borrowed into a docker box
  under a subprocess-defaulting harness was wrongly refused, and the mirror
  case wrongly allowed. validate_task_pairing now takes the resolved
  runtime_config; Agent already resolved it and keyed its cache on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
verifiers' pyproject had no [tool.ruff] section, so ruff running inside
prime-rl-topologies/deps/verifiers walked up and inherited prime-rl's
line-length = 120 — the second commit in a row arrived formatted against
this repo's 88. Pin an explicit section (at ruff defaults) so config
discovery stops at the repo boundary, and reformat the four drifted files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds what the reviews surfaced but the ledger didn't yet record: instance-level
retries (Topology.complete as the predicate, the RetryConfig slot kept open for
it), right-sizing graph wire payloads (eval-mode servers now ship training
tensors nobody reads), the best-of-N built-in as the group-reward successor and
migration story, the reviewed config-layer cleanup pass (topology.py
authoring/runner split killing the EnvConfig model_rebuild trick, RolloutLimits
duplication, the multiplex name collision, timeout vocabulary, dead env_id) with
its considered-and-rejected list, and the PR #1939 reconciliation end state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"""


def extract_boxed(text: str) -> str | 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 proposer_solver_v1/topology.py:45

extract_boxed returns None when the last \boxed{ in the reply is unbalanced, even if an earlier \boxed{...} was valid. For example, a reply containing \boxed{123} followed later by an incomplete \boxed{ incorrectly returns None, so the solver loses its parseable reward despite a valid boxed answer. Consider scanning for the last balanced \boxed{...} occurrence rather than starting only at the rightmost literal \boxed{.

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

`extract_boxed` returns `None` when the last `\boxed{` in the reply is unbalanced, even if an earlier `\boxed{...}` was valid. For example, a reply containing `\boxed{123}` followed later by an incomplete `\boxed{` incorrectly returns `None`, so the solver loses its `parseable` reward despite a valid boxed answer. Consider scanning for the last *balanced* `\boxed{...}` occurrence rather than starting only at the rightmost literal `\boxed{`.

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