Skip to content

Commit 4d8f9a3

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 cb9c849 commit 4d8f9a3

69 files changed

Lines changed: 1595 additions & 1286 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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
- **Code is the source of truth**: before writing anything, read the v1 code for existing helpers, harnesses, and interfaces instead of reinventing them. Prefer verifiers-native task, trace, server, harness, and runtime interfaces over repeated path/import/discovery plumbing in user packages.
66
- **Minimal config surface**: expose as few knobs as possible, but as many as needed.
7-
- **Keep tasksets small**: a basic taskset fits in a few dozen idiomatic lines — typed data/task/config classes, `load()`, and decorated scoring on the task. Don't override `Taskset.__init__` (implement `load()`); don't override `Harness.__init__` or `User.__init__` (use `setup()`).
7+
- **Keep tasksets small**: a basic taskset fits in a few dozen idiomatic lines — typed data/task/config classes, `load()`, and decorated scoring on the task. Don't override `Taskset.__init__` (implement `load()`); don't override `Harness.__init__` (use `setup()`).
88

99
## Running code
1010

1111
- **Always use uv**: run code and commands with `uv run`, never raw `python`. Make sure `uv` is installed ([docs](https://docs.astral.sh/uv/getting-started/installation/)).
12-
- **Scaffold environments**: create a new taskset/environment with `uv run init <name>` (`uv run init -h` lists options like `-T`/`-U`/`-H`), and run evals with `uv run eval <taskset>`.
12+
- **Scaffold environments**: create a new taskset/environment with `uv run init <name>` (`uv run init -h` lists options like `-T`/`-H`), and run evals with `uv run eval <taskset>`.
1313
- **Validate TOML first**: validate config `.toml` files before running them.
1414
- **Don't add dependencies**: never add dependencies or optional extras to the top-level `pyproject.toml`.
1515

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: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,63 @@ trainable. Everything beyond the arrow is a parameter, not a concept:
3636
runtime. `agent.provision(task)` hands the program a box to place runs into. A
3737
different model is a different agent — construct another `Agent` (injecting the
3838
shared client) rather than swapping contexts per run.
39-
- **`shared_tools=`** wires live `SharedToolServer`s (taskset-scoped MCP, served
39+
- **`tools=`** wires live `SharedToolServer`s (taskset-scoped MCP, served
4040
once by their owner) into that run. Per run, not per agent, and borrowed like the
4141
others — counted in the pairing check.
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
@@ -85,12 +85,41 @@ checked in as `configs/agentic_judge.toml` (`uv run eval @ configs/agentic_judge
8585
exporting an `Env` subclass via `__all__`, or a Hub `org/name[@version]`
8686
and its `EnvConfig` surface typed on the CLI (`--env.<agent>.*`, `-h` renders
8787
them). Empty (the default) keeps the taskset's own story: the env its package
88-
ships (a *recipe* env like `code_golf_v1`, where the interaction is intrinsic to
89-
the data), else `SingleAgentEnv`. An explicit id wins over a bundled recipe env.
88+
ships (a *recipe* env like `code_golf_v1` or `kuhn_poker_v1`, where the
89+
interaction is intrinsic to the data), else `SingleAgentEnv`. An explicit id wins
90+
over a bundled recipe env.
9091

9192
Bundled envs (`verifiers/v1/envs/`):
9293

9394
| id | agents | what it does |
9495
| --- | --- | --- |
9596
| `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. |
9697
| `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. |
98+
| `user-sim` | `assistant`, `user` | a modeled user (an untrainable `user` agent — `setup()` — 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. |
99+
100+
## User simulation: the user is just another agent
101+
102+
There is exactly one exchange mechanism: the chat session (`agents.<name>.chat(task)`)
103+
— whoever calls `turn()` is the run's user, and each turn runs one harness segment
104+
(the program runs until it yields, the caller answers its final message, the next
105+
segment resumes the exchange with the answer). A prompt-less task is opened by the
106+
first `turn(message)`; a prompted task speaks first (take its opening reply with a
107+
bare `turn()`). Who computes the turns is the env's control flow, not framework
108+
machinery:
109+
110+
```python
111+
class SortEnv(vf.SingleAgentEnv):
112+
async def run(self, task, agents):
113+
# a pre-scripted episode: the task is prompt-less, so the first turn opens
114+
async with agents.agent.chat(task) as session:
115+
for prompt in task.data.info["user_turns"]:
116+
if (await session.turn(prompt)).stopped:
117+
break # a limit or @stop ended the run
118+
```
119+
120+
A *scripted* user is a plain loop like this (a game engine stepping in-process works
121+
the same way — see the bundled `textarena` taskset). A *modeled* user is another
122+
agent: open both sessions and relay their `turn()`s into each other — see the bundled
123+
`user-sim` env, `environments/kuhn_poker_v1` (the same relay as self-play: both seats
124+
chat sessions of the run's own model), and [chat() in the Agent docs](agent.md). The
125+
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 `run()` 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
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from alphabet_sort_v1.taskset import AlphabetSortTaskset
1+
from alphabet_sort_v1.taskset import AlphabetSortEnv, AlphabetSortTaskset
22

3-
__all__ = ["AlphabetSortTaskset"]
3+
__all__ = ["AlphabetSortTaskset", "AlphabetSortEnv"]

environments/alphabet_sort_v1/alphabet_sort_v1/servers/__init__.py

Whitespace-only changes.

environments/alphabet_sort_v1/alphabet_sort_v1/servers/user.py

Lines changed: 0 additions & 29 deletions
This file was deleted.

environments/alphabet_sort_v1/alphabet_sort_v1/taskset.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
`<combined_alphabetical_sorted>` tags. The reward is the per-turn sequence similarity to the
77
ground truth, power-scaled.
88
9-
The whole conversation is driven by a `vf.User` simulator (`AlphabetSortUser` in
10-
`servers/user.py`): the task carries no prompt (`prompt=None`), so the simulator opens the
11-
conversation with the initial sort prompt — before the model is ever called — and then injects
12-
each follow-up after the assistant turn. The episode is one rollout the harness only ever sees
13-
as a single exchange. The simulator runs on the host (not colocated in the agent's runtime), so
14-
the host-driven loop reaches it on every runtime. The taskset is `INFINITE`: `load` generates
15-
episodes on demand, forever — each pass over the source name lists draws fresh turn splits, so
16-
the stream never repeats; runs bound it with `-n`. The simulator replays each generated episode.
9+
The whole conversation is driven by a scripted user: the env's `run()` replays the
10+
episode's pre-generated `user_turns` through a chat session. The task carries no prompt
11+
(`prompt=None`), so the first `turn()` opens the conversation with the initial sort
12+
prompt, and each follow-up turn resumes the assistant onto the conversation (the
13+
assistant yields, the session answers, the exchange resumes); when the turns run out,
14+
leaving the session ends the exchange. The taskset is
15+
`INFINITE`: `load` generates episodes on demand, forever — each pass over the source name
16+
lists draws fresh turn splits, so the stream never repeats; runs bound it with `-n`.
1717
"""
1818

1919
import difflib
@@ -26,8 +26,6 @@
2626

2727
import verifiers.v1 as vf
2828

29-
from alphabet_sort_v1.servers.user import AlphabetSortState, AlphabetSortUser
30-
3129
DATASET = "kalomaze/alphabetic-arxiv-authors-it1"
3230
SEED = 1337420
3331

@@ -37,7 +35,6 @@ class AlphabetSortTaskConfig(vf.TaskConfig):
3735
"""Exponent applied to each turn's sequence-similarity score (higher = sharper penalty)."""
3836
power_per_turn: bool = True
3937
"""Power-scale each turn then average (True), or average raw similarities then power once (False)."""
40-
user: vf.UserConfig = vf.UserConfig()
4138

4239

4340
class AlphabetSortConfig(vf.TasksetConfig):
@@ -56,20 +53,13 @@ class AlphabetSortConfig(vf.TasksetConfig):
5653

5754
class AlphabetSortTaskData(vf.TaskData):
5855
info: dict
59-
"""The pre-generated episode: the `user_turns` the simulator reveals one by one (the opening
60-
sort prompt, then the follow-ups), the per-turn `ground_truths` the reward grades against,
61-
and `num_turns`. The row itself carries no prompt — the simulator opens the conversation."""
62-
63-
64-
class AlphabetSortTask(
65-
vf.Task[AlphabetSortTaskData, AlphabetSortState, AlphabetSortTaskConfig]
66-
):
67-
user = AlphabetSortUser
56+
"""The pre-generated episode: the `user_turns` the env's scripted user reveals one by one
57+
(the opening sort prompt, then the follow-ups), the per-turn `ground_truths` the reward
58+
grades against, and `num_turns`. The row itself carries no prompt — the scripted user
59+
opens the conversation."""
6860

69-
@vf.stop
70-
async def user_finished(self, trace: vf.Trace) -> bool:
71-
return trace.state.user_finished
7261

62+
class AlphabetSortTask(vf.Task[AlphabetSortTaskData, vf.State, AlphabetSortTaskConfig]):
7363
@vf.reward(weight=1.0)
7464
async def alphabet_sort(self, trace: vf.Trace) -> float:
7565
ground_truths = self.data.info["ground_truths"]
@@ -107,6 +97,18 @@ async def alphabet_sort(self, trace: vf.Trace) -> float:
10797
return avg if self.config.power_per_turn else avg**self.config.similarity_power
10898

10999

100+
class AlphabetSortEnv(vf.SingleAgentEnv):
101+
"""Replays each episode's pre-generated user turns as the run's user."""
102+
103+
async def run(self, task, agents):
104+
# A chat session replaying the pre-generated episode: the task carries no
105+
# prompt, so the first turn opens the conversation with the initial sort.
106+
async with agents.agent.chat(task) as session:
107+
for prompt in task.data.info["user_turns"]:
108+
if (await session.turn(prompt)).stopped:
109+
break
110+
111+
110112
class AlphabetSortTaskset(vf.Taskset[AlphabetSortTask, AlphabetSortConfig]):
111113
INFINITE = True
112114

@@ -191,8 +193,8 @@ def sort_key(s: str) -> str:
191193
yield AlphabetSortTask(
192194
AlphabetSortTaskData(
193195
idx=idx,
194-
# No prompt on the row: the simulator opens with the sort prompt, then
195-
# the follow-ups — one user turn per `user_turns` entry.
196+
# No prompt on the row: the scripted user opens with the sort prompt,
197+
# then the follow-ups — one user turn per `user_turns` entry.
196198
prompt=None,
197199
info={
198200
"user_turns": [initial_prompt, *follow_ups],

0 commit comments

Comments
 (0)