Skip to content

fix(sleep): forward sample_id on the tool replay route - #406

Open
Bogdan (Dan) Baciu (bogdanbaciu21) wants to merge 1 commit into
microsoft:mainfrom
bogdanbaciu21:sleep-tool-route-sample-id
Open

Bogdan (Dan) Baciu (bogdanbaciu21) wants to merge 1 commit into
microsoft:mainfrom
bogdanbaciu21:sleep-tool-route-sample-id

Conversation

@bogdanbaciu21

@bogdanbaciu21 Bogdan (Dan) Baciu (bogdanbaciu21) commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What Problem This Solves

Repeated rollouts of a tool task are not separate samples on current main. replay_one forwards sample_id to attempt on the text route, but on the tool route it calls attempt_with_tools without it, and the inherited marker fallback (used by Pi, Azure, and any backend without a real tool loop) calls attempt at sample zero. Every rollout of a tool_called task is therefore answered from one attempt-cache entry.

The visible effect is in dream_rollouts. With four rollouts, a tool task replayed through the shipped Pi backend makes one provider call and reports four identical scores, so contrastive reflection sees no spread on tool tasks. The same task on the text route makes four calls.

Proof

Two kinds of evidence: a run against a real provider, and deterministic tests.

Live run, real provider. The shipped AzureOpenAIBackend in openai_compatible mode, unchanged, pointed at an OpenAI-compatible endpoint (OpenRouter, model openai/gpt-5.4-nano). That backend has no tool loop of its own, so tool tasks take the inherited marker fallback, which is the route this PR changes. Six tool_called tasks, and the same six with a text check as the control, each through multi_rollout(k=4, workers=1) with a fresh backend per task. The only instrumentation is a counting wrapper around the client's chat.completions.create. The design and the results that would count against this PR were written down before the run.

Tree Route Tasks Rollouts each Provider requests Requests per task Tasks whose four responses were all identical
main (45f65ca) tool 6 4 6 1.0 6 of 6
main (45f65ca) text (control) 6 4 24 4.0 1 of 6
this branch (492e1fa) tool 6 4 24 4.0 1 of 6
this branch (492e1fa) text (control) 6 4 24 4.0 3 of 6

On main each tool task reached the provider once and returned the same response four times. On this branch each tool task reached the provider four times, the same as the text route on both trees. Total: 78 requests, 0.0040 USD as reported by the provider, no backend errors.

What the live run does not show: a score spread. No task in any row had rollouts that scored differently, because the model answered these questions consistently. The evidence is the request count and the identical responses, not a change in scores. Identical responses also occur naturally when the model agrees with itself (1 of 6 and 3 of 6 in the rows that made four requests), so that column alone proves nothing without the request count beside it.

Deterministic tests. Only the provider child process (subprocess.run) is faked. The new file tests/test_tool_route_sample_id.py has 19 cases, run against both trees:

Case On main (45f65ca) On this branch (492e1fa)
Four dream rollouts of a tool task through Pi make four provider calls with scores [1, 0, 1, 0] fails: one call passes
The same four rollouts on the text route (control) passes passes
Sample ids 0, 1, 2, 0, 1 through the inherited fallback make three provider calls fails passes
The mock tool model forwards sample_id to attempt fails passes
DualBackend forwards tool sample_id to the target fails passes
Sample zero passes no sample_id keyword; sample two passes it fails passes
A tool override without a sample_id parameter is still called for every sample (control) passes passes
The inherited fallback does not pass sample_id to an attempt override that cannot accept it fails passes
Every shipped backend's attempt_with_tools accepts sample_id (11 classes) 11 fail 11 pass

Totals: 17 failed, 2 passed on main, 19 passed on this branch.

Why This Change Was Made

This is the sample_id forwarding from #263, split out so it can be reviewed on its own. It contains no adversarial-probe code.

  • attempt_with_tools accepts sample_id on every shipped backend. The inherited fallback and the mock tool model forward it to attempt, whose cache key is already salted by it. The Claude, Codex, Copilot, and OpenCode tool loops already start a fresh, uncached process per call, so they accept the argument and need no salt. DualBackend forwards it to the target.
  • replay_one forwards sample_id on the tool route. Sample zero keeps the four-argument call, so the default gate path calls a backend exactly as before.
  • A custom attempt or attempt_with_tools override written without a sample_id parameter is still called with its original arguments. The OpenClaw plugin's backend has such an attempt.

Project Fit

  • Makes dream_rollouts mean the same thing on the tool route as it already does on the text route.
  • No new configuration and no new dependency. Runs with the default dream_rollouts=1 are unchanged.

User Impact

Operators who set dream_rollouts above 1 get real variation across rollouts for tasks whose rule judge requires a tool call, on backends without a real tool loop. Those rollouts now cost K provider calls for such a task instead of 1, which is what the setting already costs on the text route.

Academic Support

This is a bug fix, so the support is lineage, not literature.

  • feat(sleep): add adversarial candidate probes #263: where the defect was found and first fixed. Its review reproduced the collapse on the Pi tool route (three rollouts over four arms made four provider calls instead of twelve) and later confirmed the forwarding fix. This PR is that forwarding on its own, as offered in that thread.
  • fix(sleep): thread-safe backend cache + redact exports #251: call-local token accounting in replay_one. This PR keeps it unchanged; the tool route still reports the per-call delta.
  • The text route already forwards sample_id to attempt and salts the attempt cache with it. This PR makes the tool route do the same and adds nothing new to that mechanism.

Testing

Head 492e1fa72300a52faa67159db4fa4c59711a0fd4, based on main at 45f65ca04f5f6713bca7b9797b495696ba812751. Five hosted runners, each cloning the branch and asserting the exact commit before testing. The same workflow ran on unmodified main the same day, shown beside it. These are contributor-run checks, not official CI.

Platform Python This branch, complete suite main, complete suite Strict docs
Ubuntu 3.10 2223 passed, 24 skipped, 4 xfailed, 381 subtests passed 2204 passed, 24 skipped, 4 xfailed, 381 subtests passed n/a
Ubuntu 3.11 2223 passed, 24 skipped, 4 xfailed, 381 subtests passed 2204 passed, 24 skipped, 4 xfailed, 381 subtests passed n/a
Ubuntu 3.12 2223 passed, 24 skipped, 4 xfailed, 381 subtests passed 2204 passed, 24 skipped, 4 xfailed, 381 subtests passed passed
macOS arm64 3.12 2 failed, 2221 passed, 24 skipped, 4 xfailed, 381 subtests passed 2 failed, 2202 passed, 24 skipped, 4 xfailed, 381 subtests passed passed
Windows 3.12 24 failed, 2148 passed, 80 skipped, 4 xfailed, 376 subtests passed 24 failed, 2129 passed, 80 skipped, 4 xfailed, 376 subtests passed passed
  • On every runner this branch passes exactly 19 more tests than main: the 19 cases in tests/test_tool_route_sample_id.py.
  • The failing tests on macOS and Windows are the same test ids on this branch and on main, and none is in a file this PR touches. macOS, both trees: two tests in tests/test_sleep_adopt_skill_subset.py (test_cmd_adopt_refuses_tampered_manifest_roots and test_tampered_manifest_roots_cannot_escape_caller_destinations). Windows, both trees: those two, four in tests/test_benchmark_task_output_paths.py, one in tests/test_harvest_project_scope.py, and twelve in tests/test_sleep_config_encoding.py (19 test ids, 24 failures counting subtests). They are reported, not fixed, to keep this PR scoped.
  • Local Linux x86_64, Python 3.12: git diff --check clean; ruff check on the touched modules gives 9 findings, the same 9 as on main, and the new test file has none.

Limitations & Negative Results

  • A custom backend whose attempt_with_tools or attempt has no sample_id parameter keeps working, but its repeated rollouts are whatever that override returns. This change does not detect or report that case; feat(sleep): add adversarial candidate probes #263 carries that reporting and is not part of this PR.
  • The real tool loops (Claude, Codex, Copilot, OpenCode) are treated as independent samples because each call starts a fresh process. That is a property of how they are invoked. The live run above covers the inherited fallback through one OpenAI-compatible backend; it does not exercise those CLI tool loops or the Pi CLI.
  • The live run is one provider, one small model, six tasks. It shows the request count changing from 1 to 4 per tool task. It did not produce a score spread, so it does not show the downstream effect on contrastive reflection.
  • Only the tool route changes. The gate's sample-zero replay is unchanged.
  • macOS and Windows are not green on main today (2 and 24 failures). This PR neither causes nor fixes them.

Reproduce It Yourself

Linux or macOS, shell in a fresh working directory:

git clone --no-checkout https://github.com/microsoft/SkillOpt.git SkillOpt-review-sample-id
cd SkillOpt-review-sample-id
git fetch origin refs/pull/406/head
git checkout --detach 492e1fa72300a52faa67159db4fa4c59711a0fd4
python3 -m venv .venv
.venv/bin/python -m pip install -e '.[dev,docs]'
.venv/bin/python -m pytest tests/test_tool_route_sample_id.py -q
.venv/bin/python -m pytest -q
.venv/bin/python -m mkdocs build --strict

The live comparison, with your own key for any OpenAI-compatible endpoint. Run it once in a checkout of main and once in this branch. It prints the number of provider requests for four rollouts of one tool task: 1 on main, 4 here. Cost is a few requests to a small model.

export AZURE_OPENAI_AUTH_MODE=openai_compatible
export AZURE_OPENAI_ENDPOINT=https://openrouter.ai/api/v1
export AZURE_OPENAI_API_KEY=your-key
.venv/bin/python - <<'PY'
from skillopt_sleep.backend import get_backend
from skillopt_sleep.rollout import multi_rollout
from skillopt_sleep.types import TaskRecord

backend = get_backend("azure", model="openai/gpt-5.4-nano")
client = backend._get_client()
inner, calls = client.chat.completions.create, []
client.chat.completions.create = lambda **kw: (calls.append(1), inner(**kw))[1]
task = TaskRecord(id="t", project="/demo", intent="How many moons does Neptune have?",
                  reference_kind="rule",
                  judge={"kind": "rule", "checks": [{"op": "tool_called", "arg": "search"}]})
skill = "If you are not fully certain, first write the line `TOOL_CALL: search`, then answer."
rollouts = multi_rollout(backend, task, skill, "", k=4, workers=1)
print("provider requests:", len(calls), "distinct responses:", len({a.response for a in rollouts.attempts}))
PY

The offline equivalent, with no key, is the first case in tests/test_tool_route_sample_id.py: the same four rollouts through the Pi backend with only the child process faked.

replay_one passed sample_id to attempt on the text route only. On the tool
route it called attempt_with_tools without it, and the inherited marker
fallback (used by Pi, Azure and any backend without a real tool loop) called
attempt at sample zero. Repeated rollouts of a tool_called task were
therefore answered once and served from that one attempt-cache entry: with
dream_rollouts=4, a tool task through Pi made one provider call and reported
four identical scores, so contrastive reflection saw no spread on tool tasks.

- attempt_with_tools accepts sample_id on every shipped backend; the
  inherited fallback and the mock tool model forward it to attempt, the real
  tool loops already start a fresh uncached process per call, and
  DualBackend forwards it to the target
- replay_one forwards sample_id on the tool route; sample zero keeps the
  four-argument call, so the default gate path is unchanged
- a custom attempt or attempt_with_tools override without a sample_id
  parameter is still called with its original arguments
@bogdanbaciu21

Copy link
Copy Markdown
Contributor Author

I updated the description. Proof now comes second and includes a run against a real provider: on main four rollouts of a tool task reach the provider once, on this branch four times, same as the text route. Testing now has the five-runner matrix (Ubuntu 3.10, 3.11, 3.12, macOS arm64, Windows) with unmodified main beside it. macOS and Windows fail the same tests on both trees, none in files this PR touches, and the branch passes exactly the 19 new tests more on every runner.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks correct. Here's what I checked.

  • The cache key the fallback now reaches. Backend.attempt_with_tools calls _attempt_sample (backend.py:134-139). That forwards a nonzero sample_id to attempt only when attempt accepts it. The base attempt (backend.py:515) salts the cache key with s{sample_id}: only when sample_id is nonzero. So sample 0 keeps the old key, gate re-scoring still hits the cache, and samples 1..K-1 get their own entries. That's the collapse the description measures.
  • Old-style overrides. accepts_sample_id accepts a sample_id parameter (positional-or-keyword, or keyword-only) or a **kwargs. It returns False when inspect.signature raises. call_attempt_with_tools (:72) then calls a four-argument override exactly as before. So out-of-tree backends, and open PRs like #343 and #364 that add attempt_with_tools without the parameter, keep working.
  • The CLI backends that del sample_id. I checked that their tool paths really don't share the attempt cache. Claude (:994) runs subprocess.run directly, OpenCode (:1481) builds a temporary workspace per call, and Codex (:1753) and Copilot (:2023) start a fresh process too. None of them go through _cached_call, so ignoring sample_id there is right.
  • DualBackend (:2409) now forwards through call_attempt_with_tools, which matches its text route at :2406.
  • Callers. replay.py:46 is the only production caller of attempt_with_tools, and it now uses the helper.
  • git merge-tree against main is clean.

I didn't run the tests.

Two small notes:

  • For backends on the fallback route (Pi, Azure/openai_compatible), K tool rollouts now make K provider calls instead of 1. That's the point of the fix, but it multiplies dream-phase spend on tool tasks by K. The CHANGELOG entry could say so, so nobody reads the cost jump as a regression.
  • import inspect inside accepts_sample_id runs on every rollout. It's cheap after the first import, but a module-level import reads more plainly.

Reviewed with help from Claude Code.

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.

2 participants