Skip to content

feat: add textarena taskset with a framework-driven user simulator - #1592

Merged
mikasenghaas merged 8 commits into
feat/nano-as-v1from
feat/textarena-taskset
Jun 9, 2026
Merged

feat: add textarena taskset with a framework-driven user simulator#1592
mikasenghaas merged 8 commits into
feat/nano-as-v1from
feat/textarena-taskset

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

  • Add vf.User — a first-class user simulator that is structurally a tool server (vf.Tools: an MCP server with a runtime), registered on a taskset via the new Taskset.user(task) hook (parallel to Taskset.tools).
  • Drive the user simulator from the framework. The per-rollout interception server, after each model turn with no tool call, calls the simulator's respond tool, injects its reply as a user turn, and re-prompts the model — so a whole multi-turn game plays out within a single program request, transparently to the harness and its program.py (both unchanged; they never see the simulator). With no user simulator the interception loop runs exactly once, as before.
  • Ship textarena-v1 in the tasksets package: single-player TextArena games via a generic, seed-based ta.Env → tasks conversion. game is a Literal of the verified games: Wordle-v0, Wordle-v0-long, Hangman-v0, WordLadder-v0, WordSearch-v0.
  • Game-authoritative scoring: when the episode ends, the simulator writes the game's own outcome (env.state.rewards) to a file in the runtime, and one generic @reward reads it back — no per-game guess parsing. (Win = 1.0; many games also report partial credit.)
  • Generic conversion: each task is an RNG seed (carried in the task's info dict). load_tasks seeds the game to build the instruction (per-seed for games whose prompt embeds the setup — WordLadder's start/target, WordSearch's grid) and the simulator re-seeds to the same episode. No per-game word-list or state-key knowledge, so any single-player TextArena game fits. A num_tasks config sets how many seeds to generate.
  • Add two pinned example tasksets (each a ~10-line subclass that fixes one field and reuses everything else, showing how to pin a built-in taskset): wordle-v1 (textarena_v1 with game pinned to Wordle-v0) and terminal-bench-2-v1 (harbor with dataset pinned to terminal-bench/terminal-bench-2).
  • Rename the tool surface (see Breaking). textarena + nltk are an optional textarena extra on the tasksets package.

Breaking

  • verifiers.v1.ToolSerververifiers.v1.Tools (vf.ToolServer(...)vf.Tools(...)); shipped example tasksets migrated.
  • verifiers.v1.tools.serve_mcpserve_tools.
  • Taskset.tool_servers(task)Taskset.tools(task); new sibling Taskset.user(task) returns the optional user simulator.

How the user simulator works

vf.User is consumed by verifiers/v1/interception.py, not the harness:

  • Rollout serves the user server (colocated, like a tool server) and hands the interception server an async respond(message) -> (messages, done) (verifiers/v1/user.py).
  • InterceptionServer.handle_chat loops: model turn → record a Turn → if the model made a tool call or there is no simulator, return to the program; otherwise call respond, append the user message(s) to the prompt, and re-prompt. The loop respects the framework's RolloutLimits (turns / token budget) and @stops; done ends it.
  • The game's final outcome travels out-of-band via the runtime file, so it survives even though the terminal user turn isn't a model call.

Verification

uv run eval @ configs/textarena.toml (subprocess runtime, deepseek/deepseek-v4-flash), on this branch rebased onto feat/nano-as-v1:

  • Wordle-v0, Wordle-v0-long, Hangman-v0, WordLadder-v0 reward 1.0; WordSearch-v0 solves within the turn budget (harder; longer episodes). Hangman shows both a win (1.0) and a loss (0.0), confirming game-authoritative scoring reads wins and losses. err 0.00, stop=user_completed.
  • Non-regression: gsm8k-v1 (no user simulator) runs unchanged.
  • The Tools/serve_tools rename is smoke-verified across every tool-server placement (each reward 1.0): glossary-v1 (colocated), wikispeedia-v1 (own runtime), wiki-search-v1 (shared), deepwiki-v1 (remote URL).
  • ruff check + ruff format --check clean repo-wide; uv run pytest -k "v1 or interception" passes.

Notes

  • The user simulator runs colocated (host-reachable for the subprocess/docker runtimes), so the example pins the subprocess runtime; a prime colocated simulator would need a public URL.
  • game is gated to the tested set. Out of scope: WordChains (needs 2 players), games with no word_list (GuessTheNumber, Crosswords), or LLM-gamemaster games (TwentyQuestions, Taboo, …).
  • The Wordle/Hangman -hardcore variants are intentionally excluded: their lists include capitalized proper nouns that are unwinnable under random seeding (TextArena lowercases the guess, not the stored secret).

Note

Medium Risk
Breaking public v1 API renames affect all tasksets using tools; interception’s new multi-turn loop changes rollout behavior for user-simulator tasksets but leaves non-simulator paths as a single iteration.

Overview
Introduces vf.User and Taskset.user(task) so tasksets can register an MCP-backed user simulator the framework drives (not the model). InterceptionServer.handle_chat now loops: after each assistant turn without tool calls, it calls the simulator’s respond, injects user messages, and re-prompts until done or limits/@stop fire—so multi-turn games run inside one harness chat completion while the trace still records full assistant/user turns.

Breaking renames: ToolServerTools, serve_mcpserve_tools, Taskset.tool_serversTaskset.tools; example tasksets updated.

Ships textarena-v1: seed-based TextArena episodes via a colocated user server (textarena_v1/server.py), with rewards read from a runtime outcome file written when the game ends. Optional tasksets[textarena] extra pins textarena/nltk. Adds thin wordle-v1 and terminal-bench-2-v1 example plugins plus eval configs (configs/textarena.toml, wordle.toml, terminal-bench-2.toml).

Reviewed by Cursor Bugbot for commit ff76d31. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add TextArena taskset with a framework-driven user simulator for multi-turn game rollouts

  • Adds textarena_v1 taskset that runs TextArena games (e.g. Wordle-v0) as seeded episodes, scoring via game outcomes written by a colocated user simulator.
  • Adds textarena_v1/server.py, a FastMCP server exposing a respond tool that steps the game and signals completion via an outcome file.
  • Extends InterceptionServer to support multi-turn conversations: it loops model calls and invokes the user simulator until done or the model requests tools.
  • Adds verifiers/v1/user.py with User, Respond, connect_user, and serve_user — first-class MCP-based user simulator support in the rollout framework.
  • Renames ToolServerTools, tool_servers()tools(), and serve_mcp()serve_tools() across the public API; adds optional user() hook to the Taskset base class.
  • Risk: ToolServer is no longer exported from verifiers.v1; any external taskset importing it will break.

Macroscope summarized ff76d31.

Comment thread packages/tasksets/textarena_v1/__init__.py Outdated
mikasenghaas and others added 7 commits June 9, 2026 21:59
Add `vf.User` — a first-class user simulator that is structurally a tool server
(an MCP server with a runtime), registered on a taskset via the new
`Taskset.user_server` hook. The interception server drives it: after each model
turn with no tool call it injects the simulator's reply as a user turn and
re-prompts the model, so a multi-turn exchange plays out within one program
request, transparently to the harness and its program (which never see it).
Without a user simulator the interception loop runs exactly once, as before.

Ship `textarena-v1` (working example: Wordle) in the tasksets package: the game
engine itself is the user simulator (`server.py`), seeded per-rollout with the
secret word via env; scoring is a pure function of the trace (a win is a guess
that equals the answer, parsed the way TextArena parses moves).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the tool-server surface to `vf.Tools` and the serving helper to
`serve_tools`, updating the shipped example tasksets. `User` now subclasses `Tools`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te_key

Trim the taskset config to just `game` (now required). Generate one task per word
in the game's list and let the eval select (num_tasks / shuffle), dropping the
taskset-level num_tasks/seed/max_turns. The secret-word game_state key is "secret_word"
(hardcoded in the user simulator), so the per-task answer_state_key is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the taskset hooks to `tools(task)` and `user(task)` (matching `vf.Tools` /
`vf.User`), and the `serve_shared`/`serve_tools` `servers` param to `tools`. Drop the
`Respond` intermediary at the call site (`serve_user(...) as server.user`) and unquote
the `Taskset.user` return annotation (`User` is now imported, no cycle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type `game` as a Literal of the four Wordle variants verified to work with this
taskset's assumptions (single secret word seeded via `secret_word`, exact-match
`[word]` scoring): Wordle-v0, -hardcore, -long, -long-hardcore. Other TextArena
games store the answer under a different key, lack a word_list, use different win
mechanics, or need 2 players / an LLM gamemaster.

Also drop capitalized words when sampling answers: the hardcore lists include
proper nouns, and TextArena lowercases the guess but not the stored secret, so a
capitalized answer is unwinnable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic seeding

Carry game setup in the task's `info` dict (game id + secret answer) instead of typed
per-game fields, and score from the game's own outcome: the user simulator writes
`env.state.rewards` to a file in the runtime when the episode ends, and a single generic
`@reward` reads it back — no per-game guess parsing (drops the Wordle-specific reward).

Seed the secret generically by intercepting `random.choice` during `reset`, so the game
selects our answer and derives all of its own state (Wordle's `secret_word`, Hangman's
board, ...) without us knowing each game's state keys. This makes Hangman a drop-in:
`game` now also accepts `Hangman-v0` / `Hangman-v0-hardcore` (all six verified end-to-end).

No interception changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er + WordSearch

Build tasks from RNG seeds instead of word-list sampling: load_tasks seeds the game to
reproduce each episode (building the instruction per-seed for games whose prompt embeds the
setup, e.g. WordLadder/WordSearch) and the simulator re-seeds to the same episode — no
per-game word-list or state-key knowledge, so any single-player TextArena game fits.

`game` is now Wordle-v0 / -long, Hangman-v0, WordLadder-v0, WordSearch-v0 (the -hardcore
Wordle/Hangman variants are dropped: their lists include capitalized proper nouns that are
unwinnable under random seeding). Add a `num_tasks` config (seeds have no natural count).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mikasenghaas
mikasenghaas force-pushed the feat/textarena-taskset branch from a1c9432 to cd7d920 Compare June 9, 2026 22:02
@mikasenghaas
mikasenghaas changed the base branch from fix/subprocess-tmp-leak to feat/nano-as-v1 June 9, 2026 22:02
… tasksets)

Thin example wrappers that pin a built-in taskset to one game/dataset:
- wordle-v1: textarena_v1 with `game` pinned to "Wordle-v0".
- terminal-bench-2-v1: harbor with `dataset` pinned to "terminal-bench/terminal-bench-2".

Each is a ~10-line subclass that fixes the field (Literal default) and reuses everything
else. Wired into the `examples` group + [tool.uv.sources]; configs added for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mikasenghaas
mikasenghaas marked this pull request as ready for review June 9, 2026 22:09
@mikasenghaas
mikasenghaas merged commit 33bd392 into feat/nano-as-v1 Jun 9, 2026
4 checks passed
@macroscopeapp

macroscopeapp Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a new user simulator framework with significant changes to the core interception server loop, new abstractions (User class), and TextArena game integration. The scope and runtime behavior changes warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ff76d31. Configure here.

data = await runtime.read(OUTCOME_FILE)
except (FileNotFoundError, OSError):
return 0.0
return float(json.loads(data)["reward"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outcome read errors mishandled

Medium Severity

game_reward treats a missing textarena_outcome.json as score 0.0 only when runtime.read raises FileNotFoundError or OSError. Docker and Prime runtimes raise ProgramError on a failed read, so an unfinished episode can fail scoring instead of returning 0.0.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ff76d31. Configure here.

Comment thread verifiers/v1/__init__.py
"Tools",
"run_mcp_server",
# user simulator
"User",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing v1 API documentation

Low Severity

This PR adds and exports vf.User, Taskset.user, and the Tools / serve_tools rename as core v1 APIs, but no updates appear in the user-facing docs under docs/ (for example docs/environments.md, docs/reference.md, or docs/faqs.md) describing the user simulator or the breaking tool-server rename.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: BugBot Instructions

Reviewed by Cursor Bugbot for commit ff76d31. Configure here.

pull Bot pushed a commit to Stars1233/verifiers that referenced this pull request Jun 23, 2026
…rimeIntellect-ai#1592)

* feat(v1): add textarena taskset with a framework-driven user simulator

Add `vf.User` — a first-class user simulator that is structurally a tool server
(an MCP server with a runtime), registered on a taskset via the new
`Taskset.user_server` hook. The interception server drives it: after each model
turn with no tool call it injects the simulator's reply as a user turn and
re-prompts the model, so a multi-turn exchange plays out within one program
request, transparently to the harness and its program (which never see it).
Without a user simulator the interception loop runs exactly once, as before.

Ship `textarena-v1` (working example: Wordle) in the tasksets package: the game
engine itself is the user simulator (`server.py`), seeded per-rollout with the
secret word via env; scoring is a pure function of the trace (a win is a guess
that equals the answer, parsed the way TextArena parses moves).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): rename ToolServer -> Tools and serve_mcp -> serve_tools

Rename the tool-server surface to `vf.Tools` and the serving helper to
`serve_tools`, updating the shipped example tasksets. `User` now subclasses `Tools`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(textarena): require game, one task per word, drop answer_state_key

Trim the taskset config to just `game` (now required). Generate one task per word
in the game's list and let the eval select (num_tasks / shuffle), dropping the
taskset-level num_tasks/seed/max_turns. The secret-word game_state key is "secret_word"
(hardcoded in the user simulator), so the per-task answer_state_key is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): rename Taskset.tool_servers/user_server -> tools/user

Rename the taskset hooks to `tools(task)` and `user(task)` (matching `vf.Tools` /
`vf.User`), and the `serve_shared`/`serve_tools` `servers` param to `tools`. Drop the
`Respond` intermediary at the call site (`serve_user(...) as server.user`) and unquote
the `Taskset.user` return annotation (`User` is now imported, no cycle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(textarena): restrict game to the tested Wordle family (Literal)

Type `game` as a Literal of the four Wordle variants verified to work with this
taskset's assumptions (single secret word seeded via `secret_word`, exact-match
`[word]` scoring): Wordle-v0, -hardcore, -long, -long-hardcore. Other TextArena
games store the answer under a different key, lack a word_list, use different win
mechanics, or need 2 players / an LLM gamemaster.

Also drop capitalized words when sampling answers: the hardcore lists include
proper nouns, and TextArena lowercases the guess but not the stored secret, so a
capitalized answer is unwinnable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(textarena): game-authoritative scoring via info dict + generic seeding

Carry game setup in the task's `info` dict (game id + secret answer) instead of typed
per-game fields, and score from the game's own outcome: the user simulator writes
`env.state.rewards` to a file in the runtime when the episode ends, and a single generic
`@reward` reads it back — no per-game guess parsing (drops the Wordle-specific reward).

Seed the secret generically by intercepting `random.choice` during `reset`, so the game
selects our answer and derives all of its own state (Wordle's `secret_word`, Hangman's
board, ...) without us knowing each game's state keys. This makes Hangman a drop-in:
`game` now also accepts `Hangman-v0` / `Hangman-v0-hardcore` (all six verified end-to-end).

No interception changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(textarena): generic seed-based ta.Env -> tasks; add WordLadder + WordSearch

Build tasks from RNG seeds instead of word-list sampling: load_tasks seeds the game to
reproduce each episode (building the instruction per-seed for games whose prompt embeds the
setup, e.g. WordLadder/WordSearch) and the simulator re-seeds to the same episode — no
per-game word-list or state-key knowledge, so any single-player TextArena game fits.

`game` is now Wordle-v0 / -long, Hangman-v0, WordLadder-v0, WordSearch-v0 (the -hardcore
Wordle/Hangman variants are dropped: their lists include capitalized proper nouns that are
unwinnable under random seeding). Add a `num_tasks` config (seeds have no natural count).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(examples): add wordle-v1 and terminal-bench-2-v1 (pinned example tasksets)

Thin example wrappers that pin a built-in taskset to one game/dataset:
- wordle-v1: textarena_v1 with `game` pinned to "Wordle-v0".
- terminal-bench-2-v1: harbor with `dataset` pinned to "terminal-bench/terminal-bench-2".

Each is a ~10-line subclass that fixes the field (Literal default) and reuses everything
else. Wired into the `examples` group + [tool.uv.sources]; configs added for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant