Skip to content

Commit 7e85c0c

Browse files
halleriteclaude
andcommitted
feat(v1): the exchange — chat()/turn(), segments, resume, one user mechanism
The turn half of the agent-programs work, restacked onto the synthesis run half (make_agent + Agents, flat traces with EpisodeInfo stamps, verdict-file judging, per-agent retries, the session seal). - Agent.chat(task) -> ChatSession: the caller IS the run's user; one turn() per harness segment (the program runs to its exit, the caller answers, the next segment resumes the exchange with the answer). mask_prompt hides a scenario prompt from the wire while the task still scores the real row. _EpisodeAgent.chat stamps agent name/trainable + the shared EpisodeInfo at mint, so env-driven sessions land in the episode exactly like plain runs. An exchange is caller-driven, so per-agent retries never apply to chat(). - Harness.resume: relaunch on the accreted conversation by default (SUPPORTS_MESSAGE_PROMPT); codex overrides it with a native continuation. - The user loop moves OUT of the model boundary: vf.User/Task.user (session injection, dialect extend, serve_user, UserError, the -U scaffold, the placement matrix) is deleted — chat sessions in the env's rollout() are the one exchange mechanism. The interception drive loop flattens to one turn per request, keeping per-call records and the concluded-trace seal. - textarena becomes a recipe env stepping the real engine host-side; alphabet-sort/color-codeword script their users through SingleAgentEnv sessions; openenv drives its client from rollout() (no more served user); user-sim bundled env (two-session relay, the tau-style substrate, null harness for the modeled user, untrainable via brief()) and kuhn_poker_v1 (self-play reference) added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6540a70 commit 7e85c0c

73 files changed

Lines changed: 1643 additions & 1283 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.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Use these rules when shaping public v1 APIs, configs, and environment files.
2020
- Expose as few knobs in the configs as possible, but as many as needed.
2121
- Use strict Pydantic models for structured config, tasks, messages, and state.
2222
- A basic taskset should fit in a few dozen idiomatic lines: typed data/task/config classes, `load()`, and decorated scoring on the task.
23-
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__` or `User.__init__`; use `setup()`.
23+
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__`; use `setup()`.
2424
- Refer to the code as the source of truth.
2525
- Avoid mutable module globals. Process-level locks/rate limiters and immutable constants are the narrow exceptions.
2626

assets/agents/common_best_practices.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ Use these rules when shaping public v1 APIs, configs, and environment files.
1616
- Expose as few knobs in the configs as possible, but as many as needed.
1717
- Use strict Pydantic models for structured config, tasks, messages, and state.
1818
- A basic taskset should fit in a few dozen idiomatic lines: typed data/task/config classes, `load()`, and decorated scoring on the task.
19-
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__` or `User.__init__`; use `setup()`.
19+
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__`; use `setup()`.
2020
- Refer to the code as the source of truth.
2121
- Avoid mutable module globals. Process-level locks/rate limiters and immutable constants are the narrow exceptions.

assets/lab/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Use these rules when shaping public v1 APIs, configs, and environment files.
2323
- Expose as few knobs in the configs as possible, but as many as needed.
2424
- Use strict Pydantic models for structured config, tasks, messages, and state.
2525
- A basic taskset should fit in a few dozen idiomatic lines: typed data/task/config classes, `load()`, and decorated scoring on the task.
26-
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__` or `User.__init__`; use `setup()`.
26+
- Do not override `Taskset.__init__`; implement `load()`. Do not override `Harness.__init__`; use `setup()`.
2727
- Refer to the code as the source of truth.
2828
- Avoid mutable module globals. Process-level locks/rate limiters and immutable constants are the narrow exceptions.
2929

assets/lab/environments/AGENTS.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@ The command also supports:
2828
- `-p`, `--path <dir>` — parent directory, default: `./environments`
2929
- `-T`, `--add-tool` — also scaffold a `vf.Toolset` tool server at `servers/tool.py`
3030
- Use this to create custom tools which are installed into supported harnesses via MCP.
31-
- `-U`, `--add-user` — also scaffold a `vf.User` simulator at `servers/user.py`
32-
- Use this to simulate a user interacting with the model. Not all harnesses support user simulation.
3331
- `-H`, `--add-harness` — also scaffold a custom `vf.Harness` at `harness.py`, selectable via `--env.agent.harness.id <name>`
3432
- Prefer a built-in harness unless the model needs to run inside a custom program.
3533

36-
Most tasksets do not need specific tools, user simulations or custom harnesses.
34+
Most tasksets do not need specific tools or custom harnesses. (To simulate a user interacting with the model, open a chat session from an env's `rollout()` and script the user's turns — see the [Agent docs](https://github.com/PrimeIntellect-ai/verifiers/blob/main/docs/v1/agent.md).)
3735

3836
> For a production-scale catalog of tasksets, see the companion [`research-environments`](https://github.com/PrimeIntellect-ai/research-environments) repository.
3937
@@ -413,12 +411,41 @@ checked in as `configs/agentic_judge.toml` (`uv run eval @ configs/agentic_judge
413411
exporting an `Environment` subclass via `__all__`, or a Hub `org/name[@version]`
414412
and its `EnvConfig` surface typed on the CLI (`--env.<agent>.*`, `-h` renders
415413
them). Empty (the default) keeps the taskset's own story: the env its package
416-
ships (a *recipe* env like `code_golf_v1`, where the interaction is intrinsic to
417-
the data), else `SingleAgentEnv`. An explicit id wins over a bundled recipe env.
414+
ships (a *recipe* env like `code_golf_v1` or `kuhn_poker_v1`, where the
415+
interaction is intrinsic to the data), else `SingleAgentEnv`. An explicit id wins
416+
over a bundled recipe env.
418417

419418
Bundled envs (`verifiers/v1/envs/`):
420419

421420
| id | agents | what it does |
422421
| --- | --- | --- |
423422
| `best-of-n` | `agent` | `--env.n` independent attempts per rollout; `score()` marks 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. |
424423
| `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. |
424+
| `user-sim` | `assistant`, `user` | a modeled user (an untrainable `user` agent — `brief()` — on the `null` harness by default) opens and drives the conversation from the task's prompt-as-scenario (`--env.persona`); the assistant plays the same task through a masked chat session (`mask_prompt`) — the prompt is hidden from its harness while the task's own rewards and judges still score the real row. The substrate for tau-bench-style evals. |
425+
426+
### User simulation: the user is just another agent
427+
428+
There is exactly one exchange mechanism: the chat session (`agents.<name>.chat(task)`)
429+
— whoever calls `turn()` is the run's user, and each turn runs one harness segment
430+
(the program runs until it yields, the caller answers its final message, the next
431+
segment resumes the exchange with the answer). A prompt-less task is opened by the
432+
first `turn(message)`; a prompted task speaks first (take its opening reply with a
433+
bare `turn()`). Who computes the turns is the env's control flow, not framework
434+
machinery:
435+
436+
```python
437+
class SortEnv(vf.SingleAgentEnv):
438+
async def rollout(self, task, agents):
439+
# a pre-scripted episode: the task is prompt-less, so the first turn opens
440+
async with agents.agent.chat(task) as session:
441+
for prompt in task.data.info["user_turns"]:
442+
if (await session.turn(prompt)).stopped:
443+
break # a limit or @stop ended the run
444+
```
445+
446+
A *scripted* user is a plain loop like this (a game engine stepping in-process works
447+
the same way — see the bundled `textarena` taskset). A *modeled* user is another
448+
agent: open both sessions and relay their `turn()`s into each other — see the bundled
449+
`user-sim` env, `environments/kuhn_poker_v1` (the same relay as self-play: both seats
450+
chat sessions of the run's own model), and [chat() in the Agent docs](https://github.com/PrimeIntellect-ai/verifiers/blob/main/docs/v1/agent.md). The
451+
user runs in the eval process, so there is nothing to declare, place, or serve.

configs/textarena.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# textarena — single-player TextArena games driven by a user simulator (vf.User).
1+
# textarena — single-player TextArena games, the engine playing the user.
22
# Supported (--env.taskset.game): Wordle-v0, Wordle-v0-long, Hangman-v0, WordLadder-v0,
3-
# WordSearch-v0. The framework's interception server plays the game; the harness never sees
4-
# it. The user sim runs colocated in the harness's runtime.
3+
# WordSearch-v0. The env's rollout() steps the real engine host-side as the run's user;
4+
# the harness only ever sees the conversation.
55
#
66
# uv run eval @ configs/textarena.toml --model <small-instruct-model>
77
num_tasks = 5
@@ -11,8 +11,8 @@ num_rollouts = 2
1111
id = "textarena"
1212
game = "Wordle-v0"
1313

14-
[env.agent]
14+
[env.player]
1515
max_turns = 15
1616

17-
[env.agent.harness]
17+
[env.player.harness]
1818
id = "null"

configs/wordle.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# wordle-v1 — the textarena taskset pinned to Wordle, driven by a user simulator.
1+
# wordle-v1 — the textarena taskset pinned to Wordle, the engine playing the user.
22
#
33
# uv run eval @ configs/wordle.toml --model <small-instruct-model>
44
num_tasks = 5
@@ -7,8 +7,8 @@ num_rollouts = 2
77
[env.taskset]
88
id = "wordle-v1"
99

10-
[env.agent]
10+
[env.player]
1111
max_turns = 8
1212

13-
[env.agent.harness]
13+
[env.player.harness]
1414
id = "null"

docs/v1/agent.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,57 @@ trainable. Everything beyond the arrow is a parameter, not a concept:
4242
- **retries are the agent's own**: `run` reruns its rollout while the trace ends
4343
with a retryable error (`--env.<agent>.retries.max_retries`, flat
4444
include/exclude), with backoff — never into a borrowed box, whose state is no
45-
longer the task's start state.
45+
longer the task's start state. (A `chat()` exchange is caller-driven, so it is
46+
never auto-retried.)
47+
- **the exchange is `chat()`**, the one multi-turn surface: whoever calls `turn()`
48+
is the run's user, and each turn runs one harness SEGMENT — the program runs
49+
until it yields (exits), the caller answers its final message, and the next
50+
segment resumes the exchange with the answer (`Harness.resume`: a relaunch on
51+
the accreted conversation by default; codex continues its own recorded session
52+
natively). The harness's own tool loop runs entirely inside a segment, so tools
53+
and a simulated user compose freely. Needs a harness that can resume (a
54+
Messages prompt, or a native `resume()`).
55+
- **`chat(mask_prompt=True)`** says the task's `prompt` is the USER's side of the
56+
story — a scenario the caller pursues, not the assistant's seed. The wire hides
57+
the prompt (the caller opens instead), while the task's hooks, rewards, and
58+
judges still score the real row. This is how the bundled `user-sim` env runs
59+
the assistant on the original task.
60+
61+
## chat(): be the user yourself
62+
63+
`agent.chat(task)` is the caller side of the same channel — a full run of the task
64+
where YOU (or another agent's program) supply each user turn live:
65+
66+
```python
67+
async with agent.chat(task) as session: # task has no prompt: chat opens it
68+
reply = await session.turn("hello there") # one user message -> one vf.Reply
69+
if not reply.stopped:
70+
followup = await session.turn(f"you said: {reply.text}")
71+
print(session.trace.num_turns) # the trace is live mid-exchange
72+
# leaving the context ends the exchange (user_closed) and finishes the run:
73+
# hooks and scoring included — session.trace is the final, scored trace
74+
```
75+
76+
`turn()` returns a `vf.Reply` (`text`, `stopped`); a `stopped` reply means the run
77+
ended (a limit, a `@stop`, the harness finishing) instead of answering. And because
78+
both sides speak the same channel, "the user is just another agent" — a modeled
79+
user is a chat session relayed into another agent's run:
80+
81+
```python
82+
async with (
83+
user_agent.chat(user_task) as sim, # the modeled user (its own trace!)
84+
assistant.chat(task, mask_prompt=True) as helper,
85+
):
86+
ask = await sim.turn("Hello! How can I help you today?")
87+
while not ask.stopped:
88+
reply = await helper.turn(ask.text)
89+
if reply.stopped:
90+
break
91+
ask = await sim.turn(reply.text)
92+
```
93+
94+
Modeled-user runs stay cheap with the tool-less `null` harness: a bare chat-loop
95+
program, no tools, no container — the rollout is essentially just the model calls.
4696

4797
Chaining is a `Task` classmethod: mint the next task from earlier traces with
4898
`YourTask.from_trace(...)` and hand it to the next agent.

docs/v1/environments.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,41 @@ checked in as `configs/agentic_judge.toml` (`uv run eval @ configs/agentic_judge
163163
exporting an `Environment` subclass via `__all__`, or a Hub `org/name[@version]`
164164
and its `EnvConfig` surface typed on the CLI (`--env.<agent>.*`, `-h` renders
165165
them). Empty (the default) keeps the taskset's own story: the env its package
166-
ships (a *recipe* env like `code_golf_v1`, where the interaction is intrinsic to
167-
the data), else `SingleAgentEnv`. An explicit id wins over a bundled recipe env.
166+
ships (a *recipe* env like `code_golf_v1` or `kuhn_poker_v1`, where the
167+
interaction is intrinsic to the data), else `SingleAgentEnv`. An explicit id wins
168+
over a bundled recipe env.
168169

169170
Bundled envs (`verifiers/v1/envs/`):
170171

171172
| id | agents | what it does |
172173
| --- | --- | --- |
173174
| `best-of-n` | `agent` | `--env.n` independent attempts per rollout; `score()` marks 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. |
174175
| `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. |
176+
| `user-sim` | `assistant`, `user` | a modeled user (an untrainable `user` agent — `brief()` — on the `null` harness by default) opens and drives the conversation from the task's prompt-as-scenario (`--env.persona`); the assistant plays the same task through a masked chat session (`mask_prompt`) — the prompt is hidden from its harness while the task's own rewards and judges still score the real row. The substrate for tau-bench-style evals. |
177+
178+
## User simulation: the user is just another agent
179+
180+
There is exactly one exchange mechanism: the chat session (`agents.<name>.chat(task)`)
181+
— whoever calls `turn()` is the run's user, and each turn runs one harness segment
182+
(the program runs until it yields, the caller answers its final message, the next
183+
segment resumes the exchange with the answer). A prompt-less task is opened by the
184+
first `turn(message)`; a prompted task speaks first (take its opening reply with a
185+
bare `turn()`). Who computes the turns is the env's control flow, not framework
186+
machinery:
187+
188+
```python
189+
class SortEnv(vf.SingleAgentEnv):
190+
async def rollout(self, task, agents):
191+
# a pre-scripted episode: the task is prompt-less, so the first turn opens
192+
async with agents.agent.chat(task) as session:
193+
for prompt in task.data.info["user_turns"]:
194+
if (await session.turn(prompt)).stopped:
195+
break # a limit or @stop ended the run
196+
```
197+
198+
A *scripted* user is a plain loop like this (a game engine stepping in-process works
199+
the same way — see the bundled `textarena` taskset). A *modeled* user is another
200+
agent: open both sessions and relay their `turn()`s into each other — see the bundled
201+
`user-sim` env, `environments/kuhn_poker_v1` (the same relay as self-play: both seats
202+
chat sessions of the run's own model), and [chat() in the Agent docs](agent.md). The
203+
user runs in the eval process, so there is nothing to declare, place, or serve.

docs/v1/tasksets.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ The command also supports:
2121
- `-p`, `--path <dir>` — parent directory, default: `./environments`
2222
- `-T`, `--add-tool` — also scaffold a `vf.Toolset` tool server at `servers/tool.py`
2323
- Use this to create custom tools which are installed into supported harnesses via MCP.
24-
- `-U`, `--add-user` — also scaffold a `vf.User` simulator at `servers/user.py`
25-
- Use this to simulate a user interacting with the model. Not all harnesses support user simulation.
2624
- `-H`, `--add-harness` — also scaffold a custom `vf.Harness` at `harness.py`, selectable via `--env.agent.harness.id <name>`
2725
- Prefer a built-in harness unless the model needs to run inside a custom program.
2826

29-
Most tasksets do not need specific tools, user simulations or custom harnesses.
27+
Most tasksets do not need specific tools or custom harnesses. (To simulate a user interacting with the model, open a chat session from an env's `rollout()` and script the user's turns — see the [Agent docs](agent.md).)
3028

3129
> For a production-scale catalog of tasksets, see the companion [`research-environments`](https://github.com/PrimeIntellect-ai/research-environments) repository.
3230

0 commit comments

Comments
 (0)