feat: add textarena taskset with a framework-driven user simulator - #1592
Conversation
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>
a1c9432 to
cd7d920
Compare
… 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>
ApprovabilityVerdict: 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. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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"]) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit ff76d31. Configure here.
| "Tools", | ||
| "run_mcp_server", | ||
| # user simulator | ||
| "User", |
There was a problem hiding this comment.
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)
Triggered by project rule: BugBot Instructions
Reviewed by Cursor Bugbot for commit ff76d31. Configure here.
…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>


Summary
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 newTaskset.user(task)hook (parallel toTaskset.tools).respondtool, 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 itsprogram.py(both unchanged; they never see the simulator). With no user simulator the interception loop runs exactly once, as before.textarena-v1in thetasksetspackage: single-player TextArena games via a generic, seed-basedta.Env → tasksconversion.gameis aLiteralof the verified games:Wordle-v0,Wordle-v0-long,Hangman-v0,WordLadder-v0,WordSearch-v0.env.state.rewards) to a file in the runtime, and one generic@rewardreads it back — no per-game guess parsing. (Win = 1.0; many games also report partial credit.)infodict).load_tasksseeds 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. Anum_tasksconfig sets how many seeds to generate.wordle-v1(textarena_v1withgamepinned toWordle-v0) andterminal-bench-2-v1(harborwithdatasetpinned toterminal-bench/terminal-bench-2).textarena+nltkare an optionaltextarenaextra on thetasksetspackage.Breaking
verifiers.v1.ToolServer→verifiers.v1.Tools(vf.ToolServer(...)→vf.Tools(...)); shipped example tasksets migrated.verifiers.v1.tools.serve_mcp→serve_tools.Taskset.tool_servers(task)→Taskset.tools(task); new siblingTaskset.user(task)returns the optional user simulator.How the user simulator works
vf.Useris consumed byverifiers/v1/interception.py, not the harness:Rolloutserves the user server (colocated, like a tool server) and hands the interception server an asyncrespond(message) -> (messages, done)(verifiers/v1/user.py).InterceptionServer.handle_chatloops: model turn → record aTurn→ if the model made a tool call or there is no simulator, return to the program; otherwise callrespond, append the user message(s) to the prompt, and re-prompt. The loop respects the framework'sRolloutLimits(turns / token budget) and@stops;doneends it.Verification
uv run eval @ configs/textarena.toml(subprocess runtime,deepseek/deepseek-v4-flash), on this branch rebased ontofeat/nano-as-v1:Wordle-v0,Wordle-v0-long,Hangman-v0,WordLadder-v0reward 1.0;WordSearch-v0solves 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.gsm8k-v1(no user simulator) runs unchanged.Tools/serve_toolsrename 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 --checkclean repo-wide;uv run pytest -k "v1 or interception"passes.Notes
gameis gated to the tested set. Out of scope:WordChains(needs 2 players), games with noword_list(GuessTheNumber,Crosswords), or LLM-gamemaster games (TwentyQuestions,Taboo, …).-hardcorevariants 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.UserandTaskset.user(task)so tasksets can register an MCP-backed user simulator the framework drives (not the model).InterceptionServer.handle_chatnow loops: after each assistant turn without tool calls, it calls the simulator’srespond, injects user messages, and re-prompts untildoneor limits/@stopfire—so multi-turn games run inside one harness chat completion while the trace still records full assistant/user turns.Breaking renames:
ToolServer→Tools,serve_mcp→serve_tools,Taskset.tool_servers→Taskset.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. Optionaltasksets[textarena]extra pinstextarena/nltk. Adds thinwordle-v1andterminal-bench-2-v1example 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
textarena_v1taskset that runs TextArena games (e.g. Wordle-v0) as seeded episodes, scoring via game outcomes written by a colocated user simulator.textarena_v1/server.py, a FastMCP server exposing arespondtool that steps the game and signals completion via an outcome file.InterceptionServerto support multi-turn conversations: it loops model calls and invokes the user simulator until done or the model requests tools.verifiers/v1/user.pywithUser,Respond,connect_user, andserve_user— first-class MCP-based user simulator support in the rollout framework.ToolServer→Tools,tool_servers()→tools(), andserve_mcp()→serve_tools()across the public API; adds optionaluser()hook to theTasksetbase class.ToolServeris no longer exported fromverifiers.v1; any external taskset importing it will break.Macroscope summarized ff76d31.