Skip to content

Commit d24fb81

Browse files
halleriteclaude
andcommitted
feat(v1): the exchange — chat()/turn(), segments, resume, one user mechanism
The turn half of the agent-programs work, rebased onto the restructured run half (abstract Environment + SingleAgentEnv, one EnvConfig per env, agentic judging, 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. _RoleAgent.chat stamps roles/trainable at mint, so env-driven sessions land role-stamped exactly like plain runs. - 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) and kuhn_poker_v1 (self-play reference) added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 32c7447 commit d24fb81

78 files changed

Lines changed: 1985 additions & 1261 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: 31 additions & 4 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
@@ -400,7 +398,7 @@ uv run eval my-task-v1 --env.id agentic-judge --env.judge.harness.runtime.type d
400398
exporting an `Environment` subclass via `__all__`, or a Hub `org/name[@version]`
401399
and its `EnvConfig` surface typed on the CLI (`--env.<role>.*`, `-h` renders them).
402400
Empty (the default) keeps the taskset's own story: the env its package ships (a
403-
*recipe* env like `code_golf_v1`, where the interaction is
401+
*recipe* env like `code_golf_v1` or `kuhn_poker_v1`, where the interaction is
404402
intrinsic to the data), else `SingleAgentEnv`. An explicit id wins over a
405403
bundled recipe env.
406404

@@ -410,3 +408,32 @@ Bundled envs (`verifiers/v1/envs/`):
410408
| --- | --- | --- |
411409
| `best-of-n` | `solver` | `--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. |
412410
| `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 verdict spec is a **judge plugin** (`--env.spec.id score\|rubric\|reference`, the same registry and format as `env.taskset.task.judges`) — write your grading criteria once; the parsed verdict + per-criterion metrics land on the solver's trace exactly as the plugged tier records them. The judge's verdict 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 judge seat defaults to the taskset's default harness and 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, not on an agent. |
411+
| `user-sim` | `assistant`, `user` | a modeled user (an untrainable `user` seat, `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. |
412+
413+
### User simulation: the user is just another agent
414+
415+
There is exactly one exchange mechanism: the chat session (`agents[...].chat(task)`)
416+
— whoever calls `turn()` is the run's user, and each turn runs one harness segment
417+
(the program runs until it yields, the caller answers its final message, the next
418+
segment resumes the exchange with the answer). A prompt-less task is opened by the
419+
first `turn(message)`; a prompted task speaks first (take its opening reply with a
420+
bare `turn()`). Who computes the turns is the env's control flow, not framework
421+
machinery:
422+
423+
```python
424+
class SortEnv(vf.SingleAgentEnv):
425+
async def rollout(self, task, agents):
426+
# a pre-scripted episode: the task is prompt-less, so the first turn opens
427+
async with agents["agent"].chat(task) as session:
428+
for prompt in task.data.info["user_turns"]:
429+
if (await session.turn(prompt)).stopped:
430+
break # a limit or @stop ended the run
431+
return {"agent": session.trace}
432+
```
433+
434+
A *scripted* user is a plain loop like this (a game engine stepping in-process works
435+
the same way — see the bundled `textarena` taskset). A *modeled* user is another agent
436+
role: open both sessions and relay their `turn()`s into each other — see the bundled
437+
`user-sim` env, `environments/kuhn_poker_v1` (the same relay as self-play: both seats
438+
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
439+
user runs in the eval process, so there is nothing to declare, place, or serve.

configs/textarena.toml

Lines changed: 4 additions & 4 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
@@ -14,5 +14,5 @@ max_turns = 15
1414
id = "textarena"
1515
game = "Wordle-v0"
1616

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

configs/wordle.toml

Lines changed: 2 additions & 2 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
@@ -10,5 +10,5 @@ max_turns = 8
1010
[env.taskset]
1111
id = "wordle-v1"
1212

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

docs/v1/agent.md

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

4593
Interception follows the same borrowing story as runtimes: pass a live, already-entered
4694
`Interception` at construction (`vf.Agent(..., interception=pool)`) and several agents

docs/v1/environments.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ uv run eval my-task-v1 --env.id agentic-judge --env.judge.harness.runtime.type d
150150
exporting an `Environment` subclass via `__all__`, or a Hub `org/name[@version]`
151151
and its `EnvConfig` surface typed on the CLI (`--env.<role>.*`, `-h` renders them).
152152
Empty (the default) keeps the taskset's own story: the env its package ships (a
153-
*recipe* env like `code_golf_v1`, where the interaction is
153+
*recipe* env like `code_golf_v1` or `kuhn_poker_v1`, where the interaction is
154154
intrinsic to the data), else `SingleAgentEnv`. An explicit id wins over a
155155
bundled recipe env.
156156

@@ -160,3 +160,32 @@ Bundled envs (`verifiers/v1/envs/`):
160160
| --- | --- | --- |
161161
| `best-of-n` | `solver` | `--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. |
162162
| `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 verdict spec is a **judge plugin** (`--env.spec.id score\|rubric\|reference`, the same registry and format as `env.taskset.task.judges`) — write your grading criteria once; the parsed verdict + per-criterion metrics land on the solver's trace exactly as the plugged tier records them. The judge's verdict 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 judge seat defaults to the taskset's default harness and 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, not on an agent. |
163+
| `user-sim` | `assistant`, `user` | a modeled user (an untrainable `user` seat, `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. |
164+
165+
## User simulation: the user is just another agent
166+
167+
There is exactly one exchange mechanism: the chat session (`agents[...].chat(task)`)
168+
— whoever calls `turn()` is the run's user, and each turn runs one harness segment
169+
(the program runs until it yields, the caller answers its final message, the next
170+
segment resumes the exchange with the answer). A prompt-less task is opened by the
171+
first `turn(message)`; a prompted task speaks first (take its opening reply with a
172+
bare `turn()`). Who computes the turns is the env's control flow, not framework
173+
machinery:
174+
175+
```python
176+
class SortEnv(vf.SingleAgentEnv):
177+
async def rollout(self, task, agents):
178+
# a pre-scripted episode: the task is prompt-less, so the first turn opens
179+
async with agents["agent"].chat(task) as session:
180+
for prompt in task.data.info["user_turns"]:
181+
if (await session.turn(prompt)).stopped:
182+
break # a limit or @stop ended the run
183+
return {"agent": session.trace}
184+
```
185+
186+
A *scripted* user is a plain loop like this (a game engine stepping in-process works
187+
the same way — see the bundled `textarena` taskset). A *modeled* user is another agent
188+
role: open both sessions and relay their `turn()`s into each other — see the bundled
189+
`user-sim` env, `environments/kuhn_poker_v1` (the same relay as self-play: both seats
190+
chat sessions of the run's own model), and [chat() in the Agent docs](agent.md). The
191+
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)