Repository navigation
Python: fix(redis): keep a tool call/result pair together when trimming history - #9890
Yufeng He (he-yufeng) wants to merge 2 commits into
Conversation
`save_messages` enforced `max_messages` with `LTRIM key -max_messages -1`, cutting at a raw count boundary. That can drop an assistant message carrying a `function_call` while keeping the following `tool` message with its `function_result`, so the stored history starts with a dangling tool result. The next turn is rejected by the model (`role 'tool'` without a preceding `tool_calls`, or a function-call output without its call), and because the failed turn is never persisted the session keeps failing on every later message. Compute the trim start from the post-append history instead and advance past any leading orphaned tool output (a `*_result` or `function_approval_response` with no matching call/request in the same message), so the retained window is always model-valid. A pause-and-continue style tool pair whose call is still in the window is left intact. Regression test covers the reported case: a `function_call` then `function_result` then a plain message under a limit of two keeps only the plain message, never an orphaned result.
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent saves can delete the entire session history through non-atomic trimming, while boundary checks can retain unmatched function results that break replay.
3 open issues
What changed in this PR
This PR addresses #9851 by making Redis history trimming tool-aware, aiming to prevent retained orphaned results from breaking subsequent model requests.
Changes:
- Adds helpers to identify leading tool outputs and calculate a retention boundary.
- Replaces count-only trimming with a boundary derived from existing and newly appended messages.
- Adds an orphaned-result regression test and updates the plain-message trimming test.
| File | Description |
|---|---|
python/packages/redis/tests/test_providers.py |
Tests orphaned-result trimming and updates plain-message retention expectations. |
python/packages/redis/agent_framework_redis/_history_provider.py |
Adds tool-output detection and applies the calculated trim boundary when saving history. |
Review effort: Balanced
| full_messages: list[Message] = list(existing_messages) + list(new_messages) | ||
| if len(full_messages) > self.max_messages: | ||
| start = _trim_start_index(full_messages, self.max_messages) | ||
| await _redis_result(self._redis_client.ltrim(key, start, -1)) |
| has_output = any(_is_deferred_output_content(content.type) for content in contents) | ||
| has_source = any(_is_source_content(content.type) for content in contents) | ||
| return has_output and not has_source |
| while start < count and _begins_orphaned_output(messages[start]): | ||
| start += 1 |
Address review feedback on the tool-aware trim: - Match each deferred output to its own preceding source by call id instead of accepting "some source is present". A message holding `[result(c1), call(c2)]` is not safe when c1's call was trimmed. - Advance the cut while the retained window still holds an output whose source lies outside it, so a non-adjacent call/result group is never split. - Guard read, append, and trim with WATCH and retries so a concurrent save to the same key cannot trim from a stale absolute index and delete the whole session history. Recompute the boundary when the key changed under us.
|
Thanks for the precise scenarios. All three are valid and I have pushed a revision (03307dd) that addresses them. On the concurrent-save race: you are right that deriving the trim from a snapshot then calling On matching each result to its own call: the boundary check no longer accepts "some source is present". It collects the call ids of the sources in the window and requires every deferred output's own call id to be there, so On non-adjacent groups: the cut now advances while the retained window still holds an output whose source lies outside it, so a
|


Fixes #9851
What this PR does
RedisHistoryProvider.save_messagesenforcedmax_messageswithLTRIM key -max_messages -1, cutting the stored history at a raw count boundary. When that boundary lands between a tool call and its result, the retained list can start with afunction_resultwhosefunction_callwas trimmed away. The next turn is then rejected by the model (messages with role 'tool' must be a response to a preceeding message with 'tool_calls', orNo tool call found for function call output with call_idon the Responses API), and because the failed turn is never persisted the session keeps failing on every later message, including unrelated ones.Why
A conversation with a message limit should drop older messages while continuing to work. The fix computes the trim start from the full post-append history and advances past any leading orphaned tool output (a
*_resultcontent or afunction_approval_responsewith no matching call/request in the same message), so the retained window always begins at a message the model accepts. A tool pair whose call is still inside the window is left intact. Themax_messages=0(disabled) andNone(unlimited) paths are unchanged.Reviewer Test Plan
How to verify:
pytest packages/redis/tests/test_providers.pyand the newtest_trim_does_not_orphan_tool_result.Evidence: red-green on the reported shape. With the old
LTRIMthe test fails withExpected ltrim(..., 2, -1) / Actual ltrim(..., -2, -1)(the naive cut orphans the result). With the fix it passes, keeping only the trailing plain message. The broaderpackages/redis/testssuite passes,ruff checkandruff format --checkare clean on the changed files, andpyrightreports zero errors on the changed source when run against the project environment.Tested on: macOS, Python 3.14, against the mocked Redis client used across the provider tests. The change is Python only and platform independent.
Risk & Scope
Low risk and narrow: only the
max_messagestrimming path in the Redis history provider. It cannot orphan a tool result any more, and it never removes more than the previous code (it may keep a few extra messages when skipping a leading orphan, which is the intended fix). No API changes.Linked Issues
Fixes #9851
AI Assistance
AI assistance details: root cause analysis, implementation, regression tests, and this PR description. The author reviewed the final diff, ran the redis tests, and ran Ruff and Pyright on the changed files.