Skip to content

Python: fix(redis): keep a tool call/result pair together when trimming history - #9890

Open
Yufeng He (he-yufeng) wants to merge 2 commits into
microsoft:mainfrom
he-yufeng:fix/redis-history-trim-orphaned-tool-result
Open

Yufeng He (he-yufeng) wants to merge 2 commits into
microsoft:mainfrom
he-yufeng:fix/redis-history-trim-orphaned-tool-result

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Fixes #9851

What this PR does

RedisHistoryProvider.save_messages enforced max_messages with LTRIM 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 a function_result whose function_call was 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', or No tool call found for function call output with call_id on 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 *_result content or a function_approval_response with 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. The max_messages=0 (disabled) and None (unlimited) paths are unchanged.

Reviewer Test Plan

How to verify: pytest packages/redis/tests/test_providers.py and the new test_trim_does_not_orphan_tool_result.

Evidence: red-green on the reported shape. With the old LTRIM the test fails with Expected 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 broader packages/redis/tests suite passes, ruff check and ruff format --check are clean on the changed files, and pyright reports 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_messages trimming 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

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

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.

`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.
Copilot AI balanced review requested due to automatic review settings October 30, 2026 15:32
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Oct 30, 2026
@github-actions github-actions Bot changed the title fix(redis): keep a tool call/result pair together when trimming history Python: fix(redis): keep a tool call/result pair together when trimming history Oct 30, 2026

Copilot AI left a comment

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.

🟡 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

Comment on lines +373 to +376
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))
Comment on lines +55 to +57
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
Comment on lines +71 to +72
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.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

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 LTRIM with an absolute index could delete the whole history when two saves interleave. The read, append, and trim now run under WATCH with retries, so if the key changes between the watch and the transaction the boundary is recomputed against the new state instead of trimming from a stale index.

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 [function_result(c1), function_call(c2)] is rejected when c1's call was trimmed.

On non-adjacent groups: the cut now advances while the retained window still holds an output whose source lies outside it, so a [call(c1), approval, result(c1), done] window keeps only what is self-contained and never leaves result(c1) without its call.

packages/redis/tests passes, and Ruff and Pyright are clean on the changed files.

This branch was previously deployed

1 inactive deployment
github-app-auth — 03307dde Deployed Oct 30, 2026 by he-yufeng via add_label #26705
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: RedisHistoryProvider max_messages trimming can orphan a tool result, causing every subsequent turn to fail

2 participants