Skip to content

Commit 192fd8e

Browse files
halleriteeligottsmikasenghaas
authored
feat(v1): multi-agent api (#1939)
Co-authored-by: eligotts <78387377+eligotts@users.noreply.github.com> Co-authored-by: Mika Senghaas <mail@mikasenghaas.de>
1 parent 1f457da commit 192fd8e

122 files changed

Lines changed: 4734 additions & 1919 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# env
1+
# env
22
.venv
33
.venv/
44
venv/

assets/lab/environments/AGENTS.md

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> Managed by Prime Lab. Do not edit this file directly.
44
> `prime lab setup` and `prime lab sync` refresh it. Put project-specific agent guidance in workspace-root `AGENTS.local.md`; agents should read that file after this one if it exists.
55
6-
This file mirrors the "Tasksets" documentation page.
6+
This file mirrors the "Tasksets" and "Multi-agent environments" documentation pages.
77

88
---
99

@@ -30,7 +30,7 @@ The command also supports:
3030
- Use this to create custom tools which are installed into supported harnesses via MCP.
3131
- `-U`, `--add-user` — also scaffold a `vf.User` simulator at `servers/user.py`
3232
- Use this to simulate a user interacting with the model. Not all harnesses support user simulation.
33-
- `-H`, `--add-harness` — also scaffold a custom `vf.Harness` at `harness.py`, selectable via `--harness.id <name>`
33+
- `-H`, `--add-harness` — also scaffold a custom `vf.Harness` at `harness.py`, selectable via `--env.agent.harness.id <name>`
3434
- Prefer a built-in harness unless the model needs to run inside a custom program.
3535

3636
Most tasksets do not need specific tools, user simulations or custom harnesses.
@@ -116,7 +116,7 @@ class AdditionConfig(vf.TasksetConfig):
116116
task: AdditionTaskConfig = AdditionTaskConfig()
117117
```
118118

119-
These values can be overridden with `--taskset.num-tasks` and `--taskset.task.tolerance`, or with the equivalent TOML fields.
119+
These values can be overridden with `--env.taskset.num-tasks` and `--env.taskset.task.tolerance`, or with the equivalent TOML fields (`[env.taskset]`).
120120

121121
## Lazy and infinite tasksets
122122

@@ -241,4 +241,107 @@ class JudgeTraceTaskset(vf.Taskset[JudgedTask, SetConfig]):
241241
]
242242
```
243243

244-
To override the judge model, set `taskset.task.judge.model` in your config (it is a string).
244+
To override the judge model, set `env.taskset.task.judge.model` in your config (it is a string).
245+
246+
## Beyond one agent
247+
248+
One eval rollout doesn't have to be one agent run: agents, the control flow between
249+
agents, and cross-agent rewards are the environment's job — see
250+
[Multi-agent environments](https://github.com/PrimeIntellect-ai/verifiers/blob/main/docs/v1/environments.md).
251+
252+
## Multi-agent environments
253+
254+
One eval rollout doesn't have to be one agent run. `Environment` is abstract, and
255+
every run gets a concrete subclass: plain tasksets resolve to the bundled
256+
`SingleAgentEnv` (one `agent` playing the taskset), and a package can export
257+
its own (via `__all__`, alongside its [`Taskset`](https://github.com/PrimeIntellect-ai/verifiers/blob/main/docs/v1/tasksets.md) — the same plugin
258+
idiom as a bundled harness). An env declares its config as an `EnvConfig` subclass —
259+
each agent an `AgentConfig` field, plus its own knobs — writes `run()`, and
260+
optionally overrides `setup()` and `finalize()`:
261+
262+
```python
263+
class DebateConfig(vf.EnvConfig):
264+
pro: vf.AgentConfig = vf.AgentConfig()
265+
con: vf.AgentConfig = vf.AgentConfig()
266+
judge: vf.AgentConfig = vf.AgentConfig(model="openai/gpt-5-mini")
267+
268+
269+
class VerdictTask(vf.Task):
270+
@classmethod
271+
def from_traces(cls, task: vf.Task, pro: vf.Trace, con: vf.Trace) -> "VerdictTask":
272+
prompt = (
273+
f"Question: {task.data.prompt_text}\n\n"
274+
f"PRO argued:\n{pro.last_reply}\n\nCON argued:\n{con.last_reply}\n\n"
275+
"Who won? Reply with exactly 'pro' or 'con'."
276+
)
277+
return cls(vf.TaskData(idx=task.data.idx, prompt=prompt))
278+
279+
280+
class DebateEnv(vf.Environment[DebateConfig]):
281+
async def setup(self, agents: vf.Agents) -> None:
282+
"""Per-agent standing the env hardcodes: the judge grades the debate,
283+
so its tokens are never training data."""
284+
agents.judge.trainable = False
285+
286+
async def run(self, task: vf.Task, agents: vf.Agents) -> None:
287+
"""How the agents interact on one task: imperative Python over Agent
288+
values. A loop is rounds, a TaskGroup is fan-out, a Task classmethod is
289+
chaining. Returns nothing — every finished run joins the episode
290+
automatically, stamped with its standing."""
291+
pro, con = await asyncio.gather(agents.pro.run(task), agents.con.run(task))
292+
await agents.judge.run(VerdictTask.from_traces(task, pro, con))
293+
294+
async def finalize(self, task: vf.Task, episode: vf.Episode) -> None:
295+
"""Sibling-dependent judgement over the finished episode (per-trace
296+
judgement already ran on each trace's own task); `episode.traces` is
297+
the flat episode, each trace's `agent_name` stamp naming its agent.
298+
Attach via record_reward/record_metric, in program order."""
299+
by_agent = {t.agent_name: t for t in episode.traces}
300+
winner = (by_agent["judge"].last_reply or "").strip().lower()
301+
by_agent["pro"].record_reward("won", float(winner == "pro"))
302+
by_agent["con"].record_reward("won", float(winner == "con"))
303+
```
304+
305+
For the single-agent case none of this is machinery the user sees: `SingleAgentEnv`
306+
declares one `agent` (`--env.agent.harness.id codex`, `--env.agent.max_turns 20`),
307+
`run()` is `await agents.agent.run(task)`, and the episode carries exactly one
308+
trace.
309+
310+
The run's `[env]` block is the whole run — the env is the encompassing entity,
311+
composing three separately-chosen concerns:
312+
313+
- **`env.taskset`***what to solve*: the seed rows every rollout starts from,
314+
their data, their per-trace judgement (`--env.taskset.id`, or the positional
315+
`eval <taskset-id>`).
316+
- **each agent's `harness`***how that LLM interfaces with the world*: the
317+
program driving model calls, tools, a runtime — pinned per agent, never a
318+
run-wide flag.
319+
- **the env itself***the control flow between agents*: who runs, in what order,
320+
judged how across the finished set (`--env.id`).
321+
322+
### Reusable envs: `--env.id`
323+
324+
An interaction pattern that isn't specific to one dataset — n attempts, a judge, a
325+
modeled user — is its own plugin, paired with any taskset from the CLI:
326+
327+
```bash
328+
uv run eval gsm8k-v1 --env.id best-of-n --env.n 8
329+
uv run eval my-task-v1 --env.id agentic-judge --env.judge.harness.runtime.type docker
330+
```
331+
332+
The same pairing as TOML — `env.id` plus one `[env.<agent>]` block per agent — is
333+
checked in as `configs/agentic_judge.toml` (`uv run eval @ configs/agentic_judge.toml`).
334+
335+
`--env.id` resolves like every plugin id — a bundled env (below), a local package
336+
exporting an `Environment` subclass via `__all__`, or a Hub `org/name[@version]`
337+
and its `EnvConfig` surface typed on the CLI (`--env.<agent>.*`, `-h` renders
338+
them). Empty (the default) keeps the taskset's own story: the env its package
339+
ships (a *recipe* env like `code_golf_v1`, where the interaction is intrinsic to
340+
the data), else `SingleAgentEnv`. An explicit id wins over a bundled recipe env.
341+
342+
Bundled envs (`verifiers/v1/envs/`):
343+
344+
| id | agents | what it does |
345+
| --- | --- | --- |
346+
| `best-of-n` | `agent` | `--env.n` independent attempts per rollout; its metrics mark the argmax-reward sibling (`best`) and whether any reached `--env.threshold` (`pass_at_n`) — rejection sampling and pass@k. A single-agent env keeps the single-agent name, so `--env.agent.*` flags compose unchanged. |
347+
| `agentic-judge` | `solver`, `judge` | agent-as-judge: the solver plays the task; a code-executing judge agent verifies the finished attempt with real execution, always in its own sandbox, never on the host. The judge's task mirrors the solver task's world (same image, a fresh box in its original state) with the graded transcript uploaded (`/tmp/transcript.md`/`.json`). The verdict channel is a file: the judge writes `{"score": 0-10, "reasoning": ...}` to `/tmp/verdict.json` in its box, scraped onto its trace while the box is alive and validated STRICTLY onto the solver's trace as the `judge` reward — a missing, malformed, or off-scale verdict fails the rollout instead of clamping. The judge must land in a container: pin `--env.judge.harness.runtime.type docker\|prime`, or construction refuses. A judgement that needs no execution belongs on the plugged tier (`env.taskset.task.judges`), not on an agent. |

configs/agentic_judge.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Any taskset graded by the bundled agentic judge — the multi-agent TOML shape:
2+
# `env.id` pairs a reusable env with the taskset, and each agent is its own
3+
# [env.<agent>] block (a partial override deep-merges into the agent's declared
4+
# default; there is no run-level harness). The judge pins what makes it a
5+
# different actor: a container runtime — it verifies the solver's attempt with
6+
# real execution, never on the host — and, here, its own model off the run's.
7+
#
8+
# uv run eval @ configs/agentic_judge.toml --model <model>
9+
#
10+
# The judge writes {"score": 0-10, ...} to /tmp/verdict.json in its box; the env
11+
# validates it strictly and records it on the solver's trace as the `judge` reward.
12+
num_tasks = 2
13+
14+
[env]
15+
id = "agentic-judge"
16+
17+
[env.taskset]
18+
id = "gsm8k-v1"
19+
20+
[env.solver.harness]
21+
id = "null"
22+
23+
[env.judge]
24+
model = "z-ai/glm-5.2"
25+
26+
[env.judge.harness.runtime]
27+
type = "docker"

configs/alphabet_sort.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
# uv run eval @ configs/alphabet_sort.toml --model <small-instruct-model>
44
num_tasks = 5
55
num_rollouts = 2
6-
max_turns = 4
76

8-
[taskset]
7+
[env.taskset]
98
id = "alphabet-sort-v1"
109
min_turns = 2
1110
max_turns = 2
1211

13-
[harness]
12+
[env.agent]
13+
max_turns = 4
14+
15+
[env.agent.harness]
1416
id = "null"

configs/code_golf.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# code-golf-v1 — group rewards (@vf.group_reward) score N rollouts of a task together
2-
# (shortest / fastest of the group), so it needs >= 2 rollouts.
1+
# code-golf-v1 — the taskset ships a recipe env: one env-rollout runs
2+
# `--env.attempts` independent attempts (default 2) and scores them against
3+
# each other (shortest wins), so one rollout per task is a full comparison.
34
#
45
# uv run eval code-golf-v1 @ configs/code_golf.toml
56
num_tasks = 1
6-
num_rollouts = 2
7+
num_rollouts = 1
78

8-
[taskset]
9+
[env.taskset]
910
id = "code-golf-v1"

configs/deepwiki.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
# uv run eval @ configs/deepwiki.toml
44
num_tasks = 1
55

6-
[taskset]
6+
[env.taskset]
77
id = "deepwiki-v1"

configs/glossary.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
# uv run eval glossary-v1 @ configs/glossary.toml
44
num_tasks = 1
55

6-
[taskset]
6+
[env.taskset]
77
id = "glossary-v1"

configs/gsm8k.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
num_tasks = 5
55
num_rollouts = 3
66

7-
[taskset]
7+
[env.taskset]
88
id = "gsm8k-v1"

configs/gsm8k_rlm.toml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
#
33
# uv run eval @ configs/gsm8k_rlm.toml
44
#
5-
# The discriminator ids (which taskset, which harness) come from this file's [taskset] /
6-
# [harness] `id` — they select the schema, and the rest of the file fills the typed config,
7-
# each side validated against its specific type (GSM8KConfig / RLMHarnessConfig). prime-rl
8-
# consumes the same shape, reading the ids straight from its config (TOML-driven there too).
5+
# The discriminator ids (which taskset, which harness) come from this file's
6+
# [env.taskset] / [env.agent.harness] `id` — they select the schema, and the rest of the
7+
# file fills the typed config, each side validated against its specific type (GSM8KConfig /
8+
# RLMHarnessConfig). prime-rl consumes the same [env] shape, reading the ids straight from
9+
# its config (TOML-driven there too).
910
num_tasks = 1
1011

11-
[taskset]
12+
[env.taskset]
1213
id = "gsm8k-v1"
1314

14-
[harness]
15+
[env.agent.harness]
1516
id = "rlm"
1617
version = "main"

configs/harbor.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
# uv run eval @ configs/harbor.toml
55
num_tasks = 10
66

7-
[taskset]
7+
[env.taskset]
88
id = "harbor"
99
dataset = "terminal-bench/terminal-bench-2"
1010

11-
[harness]
11+
[env.agent.harness]
1212
id = "bash"
1313
runtime = { type = "docker" }

0 commit comments

Comments
 (0)