fix(v2): isolate kwargs for retry to prevent cache key divergence (#2454) - #2457
fix(v2): isolate kwargs for retry to prevent cache key divergence (#2454)#2457feiiiiii5 wants to merge 2 commits into
Conversation
…7-labs#2454) When `cache=` is used and the first LLM call fails validation, the reask loop appends a failed tool-call turn and a validation-feedback turn to `messages`. All 13 v2 providers do this with `kwargs = kwargs.copy()` (shallow) followed by in-place `kwargs["messages"].extend/append(...)`. Because `patch.py` passes `new_kwargs` *by reference* into the retry function and then computes the cache **store** key from `new_kwargs["messages"]` *after* the retry returns, the store key diverges from the lookup key: the store key now includes the failed tool-call turn + feedback turn, so no future call can ever produce that same key. The successful result is cached under an unreachable key -- effectively a silent cache miss for every retried request. Pass an isolated copy of `new_kwargs` into the retry function so reask-handler mutations to `messages`/`contents`/`chat_history` are not observable through the caller's `new_kwargs`. This closes the door for any future handler making the same shallow-copy-then-mutate mistake, rather than fixing each of the 13 provider handlers individually. Adds sync + async regression tests that fail on unmodified main (`call_count=3` instead of `2`) and pass with this fix.
Accidentally introduced a `User-Agent` -> `User-agent` content change when transcribing the CHANGELOG. Restore the original wording so the PR diff only contains the new 567-labs#2454 entry.
|
This fixes the same bug as #2455 (I filed #2454 and opened that PR the day before this one), so wanted to flag the overlap rather than let both sit in the queue unremarked. The core fix is functionally the same: isolate the messages/contents/chat_history list before it goes into the retry loop so reask mutations can't leak back into the kwargs the cache store key gets computed from. One real difference worth mentioning: your _isolate_kwargs_for_retry copies every matching key it finds among the three, while my isolate_retry_kwargs originally returned after the first match, so a kwargs dict carrying more than one of those keys would leave the others aliased. No current provider actually does that, but your version handles it correctly and mine didn't, so I've updated #2455 to match (isolates all three independently now instead of stopping at the first). Given that, functionally these are close to interchangeable at this point. Not going to push for one over the other, just wanted the maintainers to have the full picture instead of reviewing two PRs that look unrelated at a glance but touch the exact same bug. |
|
@ErenAta16 thanks for the transparent heads-up — appreciate you flagging the overlap rather than letting both PRs sit in the queue looking unrelated. You're right that #2455 was first (opened 7/17 20:50 UTC, mine 7/18 10:38 UTC) and that the core fix is functionally the same: shallow-copy the A few honest differences worth putting on the table for the maintainers: 1. Placement. Your 2. CHANGELOG. Mine has a CHANGELOG entry under 3. Tests. Both add regression tests; I haven't diffed them line-by-line but the angles look similar (cache key stability across a retry that touches messages). Worth a glance by the maintainer to see if there's coverage on either side worth merging. 4. The multi-key behavior you already adopted. Thanks for the explicit callout — your original early-return-after-first-match version would have left a hypothetical multi-key kwargs dict with one isolated list and two still-aliased ones. No current provider sends more than one of the three keys, but the "isolate all three independently" shape is strictly safer and costs nothing. Both PRs converge on that now. Suggested resolution. I'd lean toward the maintainers taking #2455 as the canonical PR (it was first, has the better placement, and you've already updated it to handle the multi-key case). I'm happy to close #2457 once there's a steer — or, if @jxnl prefers the CHANGELOG entry + my test angle, I can fold those into #2455 and close mine either way. Not going to push either direction; just want to spare the maintainers from reviewing two near-identical diffs. Tagging @jxnl for visibility — let me know which way you'd like to go and I'll act on it (close mine, or rebase the unique bits onto #2455). |
|
Really appreciate the thoroughness here, that's a fair and generous rundown. Added the CHANGELOG entry to #2455 ( Thanks for handling this the collaborative way instead of just racing it. |
|
Thanks @ErenAta16 — appreciate the generous response and the CHANGELOG entry on #2455. Sounds like we're aligned either way: leaving it to @jxnl to pick, and I'll close #2457 if #2455 is chosen. No further action from my side until there's a decision. |
|
Sounds good, same here. Thanks for making this an easy one to sort out. |
|
Closing in favor of #2455 as discussed with @ErenAta16 — the CHANGELOG entry was added there (ec6e640). Thanks for the smooth resolution! |
|
Sounds good, and thanks for moving the CHANGELOG entry over to #2455 rather than leaving it stranded here. One PR carrying the fix and its changelog line is cleaner than splitting them across two. |
Summary
Fixes #2454.
When
cache=is used and the first LLM call fails validation, the reask loop appends a failed tool-call turn and a validation-feedback turn tomessages. All 13 v2 providers do this withkwargs = kwargs.copy()(shallow) followed by in-placekwargs["messages"].extend/append(...).Because
patch.pypassesnew_kwargsby reference into the retry function and then computes the cache store key fromnew_kwargs["messages"]after the retry returns, the store key diverges from the lookup key: the store key now includes the failed tool-call turn + feedback turn, so no future call can ever produce that same key. The successful result is cached under an unreachable key — effectively a silent cache miss for every retried request.Root cause
The data flow that produces the divergence:
patch.pycomputes the cache lookup key fromnew_kwargs.get("messages")before callingretry_sync_v2/retry_async_v2.new_kwargsby reference into the retry function.kwargs = kwargs.copy()(shallow) and thenkwargs["messages"].extend(reask_msgs)/.append(...)— mutating the sharedmessageslist object in place.patch.pycomputes the cache store key fromnew_kwargs.get("messages")— which now includes the failed tool-call turn + validation-feedback turn.This affects all 13 v2 providers that have reask handlers with the same
kwargs = kwargs.copy()+ in-placemessages.extend/appendpattern:openai,anthropic,gemini,genai,cohere,mistral,vertexai,perplexity,openrouter,writer,xai,bedrock,groq.Fix
Pass an isolated copy of
new_kwargsinto the retry function so reask-handler mutations tomessages/contents/chat_historyare not observable through the caller'snew_kwargs. The store key is then computed from the same pristinenew_kwargsthe lookup key was computed from, so the two keys always agree.This closes the door for any future handler making the same shallow-copy-then-mutate mistake, rather than fixing each of the 13 provider handlers individually (option a from the issue).
The new helper
_isolate_kwargs_for_retry()only copies the list container for the three known message-bearing kwargs (messages,contents,chat_history); the individual message dicts are shared, which is sufficient because reask handlers onlyappend/extendnew dicts — they never mutate existing entries — and the cache key is computed from the JSON serialization of the list, which is invariant to whether the dicts are aliased or not.Tests
Adds
tests/v2/test_issue_2454.pywith sync + async regression tests that:{"name": "Ada"}— missingage) and a success on the second ({"name": "Ada", "age": 37}).create_fntwice with identical inputs.call_count == 2, not3).Both tests fail on unmodified
main(call_count == 3instead of2) and pass with this fix.No regressions in the existing v2 / cache suites (the 2 pre-existing
mistralaiimport failures are unrelated — themistralaipackage is an optional dependency not installed locally and the failures exist onmain):Lint, format, and type checks all pass:
Checklist
mainand passes with the fix.mistralaifailures pre-date this PR).[Unreleased].ruff check,ruff format --check,ty checkall clean.Closes #2454.