Skip to content

feat(training): train with Tinker on a bundle's tasks - #458

Open
polakamtejas wants to merge 1 commit into
feat/episode-run-indexfrom
feat/training-tinker
Open

polakamtejas wants to merge 1 commit into
feat/episode-run-indexfrom
feat/training-tinker

Conversation

@polakamtejas

@polakamtejas polakamtejas commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #457, which records episodes in the run index. This PR's run groups need it. Review that one first; this one retargets to main once it merges.

What

agent_env.training.tinker.AgentEnvDatasetBuilder is tinker-cookbook's rl.train dataset builder for a bundle folder's tasks:

  • Batches: each batch is groups of episodes, a group playing one task.
  • Splits: train_split and test_split are all or an eval of the bundle.
  • Reward: reward= names the scoring step when a task records more than one.
  • Concurrency: max_concurrent_episodes bounds the runs under way at once.

Each rollout is an EpisodeEnv, a cookbook MessageEnv played on the Episode API, in process:

  • The first observation starts the episode and renders the turn's system prompt, its tools (the envs' tools, and bash when the agent works in a sandbox) and its prompt with the cookbook's renderer.
  • Each step records the sampled message on the turn, then runs its tool calls. A reply without a call ends the turn. A later turn comes back as the next user message.
  • When the run ends, the reward is the task's scores under the rules build_app applies (agent_env.openenv.reward). Each score is a metric score/<step>, next to messages, tool_calls and tool_errors.
  • Tool-call arguments are typed by the tool's schema. A renderer that writes parameters as untyped text (Qwen3.5's <parameter=zip>19122</parameter>) parses each one with json.loads, which reads digits as a number. So without this, a model could never pass an id or a zip code. Qwen's own parser types arguments by the schema, as SkyRL's calls.parse does.
  • A rollout the cookbook cuts (token limit, context overflow, a structural parse error) never reaches the env. compute_group_rewards ends its turn with no reply once its group is done, so the verifiers still score it. The metric cut marks it.
  • A failed episode raises, so the cookbook's RetryOnFailure retries it or drops its group. It never trains as 0.
  • Run groups: each group's episodes are filed in the run index as <run_name>/train|eval/step-<n> (_episodes.RunGroup). The explorer then shows a task's rollouts by training step. The cookbook makes its eval groups once and replays them at every eval, so an eval group's step is read when it's played.

Bundles are prepared as the OpenEnv server prepares them (prepare_bundle(played=True)), without the agent the trainer plays.

Why in process, not through the OpenEnv server

Tinker samples tokens itself, so the trainer owns the loop. The env needs only the Episode API, and skips the pieces the server-based helpers need: an end_turn tool, probing each task's tools, and the server hop. It shares the same rules: the reward rules, score/<step> metrics, and failures left out of training.

Packaging and CI

  • The tinker extra is openenv, for the reward rules. tinker-cookbook, Tinker and torch are the trainer's to pin, as with verl and SkyRL. The extra is in dev and adds nothing heavy, so uv.lock changes by a few lines.
  • agent_env.training.tinker imports only where tinker-cookbook is installed. It's listed with the other trainer helpers as not core.
  • agent_env.training._episodes holds what needs no trainer installed: argument typing, RunGroup and TrainingSteps. It's unit-tested in the regular tier.
  • The new tst/tinker tier plays EpisodeEnv on tinker-cookbook with a stand-in renderer, so nothing samples and no Tinker key is needed. It's covered by a new tinker.yml workflow (tinker-cookbook as released and its main, CPU torch; not a required check) and by make tinker-test.

Tests

  • tst/tinker (8 tests), on tinker-cookbook 0.5.9:

    • a rollout's tool call runs on its turn, and its reply ends it with the task's reward;
    • the run is in the index under its group;
    • the turn bound ends a turn;
    • a cut rollout is scored once its group is done;
    • a bundle's splits are prepared without the played agent;
    • tasks that can't be played are left out, and a dataset whose tasks make no reward is refused.

    Breaking the cut metric fails the tier.

  • tst/unit/training/episodes_test.py: argument typing (plain, nullable and anyOf strings; integers and lists left alone) and run-group naming, including an eval's group read at the step it's played.

  • Unit tier, plain dev venv, macOS: 11,567 passed and 4 failed. The same 4 fail on feat(episode): record episodes in the run index #457 alone, without this change, so they come from this machine's environment, not from this PR: test_mcp_cli_builder ×2, and test_docker_image_github_clone ×2, whose git hangs on a local credential wrapper.

  • End to end on local Docker, Qwen3.5-9B on Tinker: a bundle of tau2-bench's retail domain. It has the store database as the env's universe, its 15 tools, 89 tasks, and a verifier comparing the database the agent leaves with the gold actions'.

    • Replaying the gold actions through the Episode API scores 1.0; doing nothing scores 0.0.
    • Three training runs (8 tasks × 4 rollouts a step, 14 steps) took held-out reward on 32 unseen tasks from 0.16–0.21 to 0.83–0.92 by step 8.
    • Since cut rollouts are scored, every rollout has a verifier score.
    • The sampler and trainer stayed on-policy (KL ≤ 6e-4), across about 900 episodes with 0 episode errors.
    • A smoke run on this branch, rebased on today's main, trained and filed its groups as …/eval/step-0000, …/train/step-0000 and …/eval/step-0001.

Not covered yet

  • Skills: a turn that gives the agent skills is refused, since the module doesn't show them to the model yet.
  • Images in tool results: they reach the model as a [image content] placeholder.
  • Remote sandboxes: only local Docker has been exercised.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 3/5

Fix the cut-rollout deadlock, preserve later-turn instructions, and satisfy the logging requirements before merging.

Fix All in CursorFindings

  1. P1 Cut rollouts can hang training ▶
  2. P1 Later turns lose changed instructions ▶
  3. P2 Logs copy full message content ▶
  4. P2 The log limit is unnamed ▶
Fix with agent prompt
### Issue 1
src/agent_env/training/tinker.py:289-291
Cut rollouts keep their episode slots until `compute_group_rewards`, which runs after the group finishes. With `group_size=16` and `max_concurrent_episodes=8`, if the first eight rollouts hit a token or parse limit, the remaining eight cannot start. The group never finishes, so scoring never releases the slots.

Finish and score each cut episode before waiting for its siblings, while still bounding active episodes.

### Issue 2
src/agent_env/training/tinker.py:203-205
Later turns append only `turn.prompt`. A task that changes its `system_prompt` between prompts still trains the model under the first turn's instructions, even though `EpisodeSpec` supports these changes. This produces rollouts and rewards for the wrong model input.

Update the rendered instructions when opening a later turn, or reject such tasks explicitly.

### Issue 3
src/agent_env/training/tinker.py:125
The rollout logs include the complete assistant response here and complete serialized tool arguments at line 133. This violates the repository directive: “Never log full response bodies, request bodies, or large serialized objects in production code.”

Keep summaries such as tool names, sizes, and error counts here, and leave full content in the episode record. This repository requirement must be satisfied before merging.

### Issue 4
src/agent_env/training/tinker.py:133
The inline `600` sets the tool-result log limit. The repository requires magic numbers to be stored as descriptively named class or instance variables. Give this character limit a name so its purpose is clear and the logging policy is easy to change. This repository requirement must be satisfied before merging.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a Tinker dataset builder that plays bundle tasks through the Episode API.

  • Tinker trains on bundle tasks through in-process episodes.
  • The Tinker helper has its own optional install and contract-test tier.

The assessment is based on source inspection; no runtime verification was performed.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Prepare bundle splits] --> B[Create task groups]
  B --> C[Acquire episode slot]
  C --> D[Start Episode API run]
  D --> E[Render turn and sample message]
  E --> F{Rollout outcome}
  F -->|Tool call| G[Record and run tools]
  G --> E
  F -->|Reply| H[End turn or finish episode]
  H -->|Finished| I[Return reward and release slot]
  F -->|Cookbook cuts rollout| J[Keep open episode and slot]
  J --> K[Wait for group to finish]
  K --> L[Score cut episode and release slot]
  K -. Waiting siblings may need held slots .-> C
Loading

Reviews (1) · Last reviewed commit: "feat(training): train with Tinker on a b..." · Reviewed by Greptile

`agent_env.training.tinker.AgentEnvDatasetBuilder` is tinker-cookbook's `rl.train` dataset builder: each batch is
groups of episodes of one task, and every rollout is an episode played in process on the Episode API, not through the
OpenEnv server, since Tinker samples itself. The turn's tools are rendered by the cookbook's renderer, each sampled
message is recorded on the turn before its calls run, a reply without a call ends the turn, and the reward is made by
the rules `build_app` applies (`openenv/reward.py`). Tool-call arguments are typed by the tool's schema, since a
renderer that writes parameters as untyped text reads digits as numbers. A rollout the cookbook cuts is scored once its
group is done; a failed episode raises for the cookbook's retry. Each group is filed in the run index as
`<run>/train|eval/step-<n>` (`_episodes.RunGroup`).

The `tinker` extra is `openenv`; tinker-cookbook, Tinker and torch are the trainer's to pin. The `tst/tinker` tier
plays episodes on tinker-cookbook with a stand-in renderer, run by `tinker.yml` and `make tinker-test`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@polakamtejas
polakamtejas requested a review from a team as a code owner November 3, 2026 00:21
Comment on lines +289 to +291
async def compute_group_rewards(self, trajectory_group: list[Trajectory],
env_group: Sequence[Env]) -> list[tuple[float, Metrics]]:
return list(await asyncio.gather(*(env.message_env.score_cut() for env in env_group)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cut rollouts can hang training

Cut rollouts keep their episode slots until compute_group_rewards, which runs after the group finishes. With group_size=16 and max_concurrent_episodes=8, if the first eight rollouts hit a token or parse limit, the remaining eight cannot start. The group never finishes, so scoring never releases the slots.

Finish and score each cut episode before waiting for its siblings, while still bounding active episodes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/training/tinker.py
Line: 289-291

Comment:
**Cut rollouts can hang training**

Cut rollouts keep their episode slots until `compute_group_rewards`, which runs after the group finishes. With `group_size=16` and `max_concurrent_episodes=8`, if the first eight rollouts hit a token or parse limit, the remaining eight cannot start. The group never finishes, so scoring never releases the slots.

Finish and score each cut episode before waiting for its siblings, while still bounding active episodes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +203 to +205
await self._open(turn)
self.history.append({"role": "user", "content": turn.prompt})
return MessageStepResult(reward=0.0, episode_done=False, next_messages=self.history)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Later turns lose changed instructions

Later turns append only turn.prompt. A task that changes its system_prompt between prompts still trains the model under the first turn's instructions, even though EpisodeSpec supports these changes. This produces rollouts and rewards for the wrong model input.

Update the rendered instructions when opening a later turn, or reject such tasks explicitly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/training/tinker.py
Line: 203-205

Comment:
**Later turns lose changed instructions**

Later turns append only `turn.prompt`. A task that changes its `system_prompt` between prompts still trains the model under the first turn's instructions, even though `EpisodeSpec` supports these changes. This produces rollouts and rewards for the wrong model input.

Update the rendered instructions when opening a later turn, or reject such tasks explicitly.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

# A call whose arguments aren't an object can't run, so it isn't recorded: the turn runs every recorded call.
await self._turn.record_model_call(
_chat_message(text, [call for call in calls if isinstance(call[2], dict)]), model=self._model_name)
logs: dict[str, str | int | float] = {"task": self._name, "episode": self._episode.id, "assistant": text}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Logs copy full message content

The rollout logs include the complete assistant response here and complete serialized tool arguments at line 133. This violates the repository directive: “Never log full response bodies, request bodies, or large serialized objects in production code.”

Keep summaries such as tool names, sizes, and error counts here, and leave full content in the episode record. This repository requirement must be satisfied before merging.

Rule Used: What: Never log full response bodies, request bodies, or large serialized objects in production code. Why: Large logs are expensive, noisy, hard to search, risk pipeline limits, and can leak sensitive data. Good: ``` logger.info('Search completed',... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/training/tinker.py
Line: 125

Comment:
**Logs copy full message content**

The rollout logs include the complete assistant response here and complete serialized tool arguments at line 133. This violates the repository directive: “Never log full response bodies, request bodies, or large serialized objects in production code.”

Keep summaries such as tool names, sizes, and error counts here, and leave full content in the episode record. This repository requirement must be satisfied before merging.

**Rule Used:** What: Never log full response bodies, request bodies, or large serialized objects in production code.  Why: Large logs are expensive, noisy, hard to search, risk pipeline limits, and can leak sensitive data.  Good: ``` logger.info('Search completed',... ([source](https://app.greptile.com/-/switch-tenant?tenant=scale-ai&next=%2Fcustom-context%3Fmemory%3Dfa8d684f-4686-4f3e-b1ef-c27453f614ea))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

self._tool_errors += 1
result = _tool_message(call, call_id, "error: the arguments aren't a JSON object")
self.history.append(result)
logs[f"tool_call_{n}"] = f"{call.function.name}({json.dumps(arguments)}) -> {str(result['content'])[:600]}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The log limit is unnamed

The inline 600 sets the tool-result log limit. The repository requires magic numbers to be stored as descriptively named class or instance variables. Give this character limit a name so its purpose is clear and the logging policy is easy to change. This repository requirement must be satisfied before merging.

Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)

Learned From
scaleapi/scaleapi#126388

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/training/tinker.py
Line: 133

Comment:
**The log limit is unnamed**

The inline `600` sets the tool-result log limit. The repository requires magic numbers to be stored as descriptively named class or instance variables. Give this character limit a name so its purpose is clear and the logging policy is easy to change. This repository requirement must be satisfied before merging.

**Rule Used:** Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. ([source](https://app.greptile.com/-/switch-tenant?tenant=scale-ai&next=%2Fcustom-context%3Fmemory%3D002e0051-41ad-46c1-9098-47433c580150))

**Learned From**
[scaleapi/scaleapi#126388](https://github.com/scaleapi/scaleapi/pull/126388)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

This branch has not been deployed

No deployments
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