Repository navigation
fix(sleep): forward sample_id on the tool replay route - #406
Open
Bogdan (Dan) Baciu (bogdanbaciu21) wants to merge 1 commit into
Open
Bogdan (Dan) Baciu (bogdanbaciu21) wants to merge 1 commit into
Bogdan (Dan) Baciu (bogdanbaciu21) wants to merge 1 commit into
Conversation
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
Contributor
Author
|
I updated the description. Proof now comes second and includes a run against a real provider: on |
Muhammad Abdullah Butt (1AbdullahButt)
left a comment
Contributor
There was a problem hiding this comment.
Looks correct. Here's what I checked.
- The cache key the fallback now reaches.
Backend.attempt_with_toolscalls_attempt_sample(backend.py:134-139). That forwards a nonzerosample_idtoattemptonly whenattemptaccepts it. The baseattempt(backend.py:515) salts the cache key withs{sample_id}:only whensample_idis 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_idaccepts asample_idparameter (positional-or-keyword, or keyword-only) or a**kwargs. It returnsFalsewheninspect.signatureraises.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 addattempt_with_toolswithout 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) runssubprocess.rundirectly, 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 ignoringsample_idthere is right. DualBackend(:2409) now forwards throughcall_attempt_with_tools, which matches its text route at:2406.- Callers.
replay.py:46is the only production caller ofattempt_with_tools, and it now uses the helper. git merge-treeagainstmainis 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 inspectinsideaccepts_sample_idruns 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Problem This Solves
Repeated rollouts of a tool task are not separate samples on current
main.replay_oneforwardssample_idtoattempton the text route, but on the tool route it callsattempt_with_toolswithout it, and the inherited marker fallback (used by Pi, Azure, and any backend without a real tool loop) callsattemptat sample zero. Every rollout of atool_calledtask 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
AzureOpenAIBackendinopenai_compatiblemode, unchanged, pointed at an OpenAI-compatible endpoint (OpenRouter, modelopenai/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. Sixtool_calledtasks, and the same six with a text check as the control, each throughmulti_rollout(k=4, workers=1)with a fresh backend per task. The only instrumentation is a counting wrapper around the client'schat.completions.create. The design and the results that would count against this PR were written down before the run.main(45f65ca)main(45f65ca)492e1fa)492e1fa)On
maineach 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 filetests/test_tool_route_sample_id.pyhas 19 cases, run against both trees:main(45f65ca)492e1fa)[1, 0, 1, 0]0, 1, 2, 0, 1through the inherited fallback make three provider callssample_idtoattemptDualBackendforwards toolsample_idto the targetsample_idkeyword; sample two passes itsample_idparameter is still called for every sample (control)sample_idto anattemptoverride that cannot accept itattempt_with_toolsacceptssample_id(11 classes)Totals: 17 failed, 2 passed on
main, 19 passed on this branch.Why This Change Was Made
This is the
sample_idforwarding from #263, split out so it can be reviewed on its own. It contains no adversarial-probe code.attempt_with_toolsacceptssample_idon every shipped backend. The inherited fallback and the mock tool model forward it toattempt, 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.DualBackendforwards it to the target.replay_oneforwardssample_idon the tool route. Sample zero keeps the four-argument call, so the default gate path calls a backend exactly as before.attemptorattempt_with_toolsoverride written without asample_idparameter is still called with its original arguments. The OpenClaw plugin's backend has such anattempt.Project Fit
dream_rolloutsmean the same thing on the tool route as it already does on the text route.dream_rollouts=1are unchanged.User Impact
Operators who set
dream_rolloutsabove 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.
replay_one. This PR keeps it unchanged; the tool route still reports the per-call delta.sample_idtoattemptand 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 onmainat45f65ca04f5f6713bca7b9797b495696ba812751. Five hosted runners, each cloning the branch and asserting the exact commit before testing. The same workflow ran on unmodifiedmainthe same day, shown beside it. These are contributor-run checks, not official CI.main, complete suitemain: the 19 cases intests/test_tool_route_sample_id.py.main, and none is in a file this PR touches. macOS, both trees: two tests intests/test_sleep_adopt_skill_subset.py(test_cmd_adopt_refuses_tampered_manifest_rootsandtest_tampered_manifest_roots_cannot_escape_caller_destinations). Windows, both trees: those two, four intests/test_benchmark_task_output_paths.py, one intests/test_harvest_project_scope.py, and twelve intests/test_sleep_config_encoding.py(19 test ids, 24 failures counting subtests). They are reported, not fixed, to keep this PR scoped.git diff --checkclean;ruff checkon the touched modules gives 9 findings, the same 9 as onmain, and the new test file has none.Limitations & Negative Results
attempt_with_toolsorattempthas nosample_idparameter 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.maintoday (2 and 24 failures). This PR neither causes nor fixes them.Reproduce It Yourself
Linux or macOS, shell in a fresh working directory:
The live comparison, with your own key for any OpenAI-compatible endpoint. Run it once in a checkout of
mainand once in this branch. It prints the number of provider requests for four rollouts of one tool task: 1 onmain, 4 here. Cost is a few requests to a small model.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.