Repository navigation
Python: fix(redis): keep core message groups atomic when trimming history #9890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
94aed0c
03307dd
89e80c4
0691149
ab94c48
3b44527
f70ad8f
0f4d0a4
9c645f9
4b37cba
986e477
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,20 +11,101 @@ | |
| import json | ||
| import warnings | ||
| from collections.abc import Awaitable, Sequence | ||
| from copy import copy | ||
| from inspect import isawaitable | ||
| from typing import Any, ClassVar, Literal, TypeVar, cast | ||
|
|
||
| import redis.asyncio as redis | ||
| from agent_framework import Message | ||
| from agent_framework._compaction import ( | ||
| _unambiguous_approval_pairs, # pyright: ignore[reportPrivateUsage] | ||
| group_messages, | ||
| ) | ||
| from agent_framework._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage] | ||
| from agent_framework._sessions import HistoryProvider, filter_new_messages | ||
| from agent_framework._telemetry import mark_feature_used | ||
| from redis.credentials import CredentialProvider | ||
| from redis.exceptions import WatchError | ||
|
|
||
| from ._feature_usage import FeatureIndex | ||
|
|
||
| _T = TypeVar("_T") | ||
|
|
||
| # Optimistic-concurrency retries for a single ``save_messages`` call. Each retry recomputes the | ||
| # trim boundary against the key state after a competing writer modified it (WATCH conflict). | ||
| _SAVE_WATCH_RETRIES = 8 | ||
|
|
||
|
|
||
| def _has_unfinished_approval(messages: Sequence[Message]) -> bool: | ||
| """Return whether an approval group still lacks at least one response.""" | ||
| request_count = sum( | ||
| content.type == "function_approval_request" for message in messages for content in message.contents | ||
| ) | ||
| paired_request_count = len(_unambiguous_approval_pairs(messages)) | ||
| return request_count > paired_request_count | ||
|
|
||
|
|
||
| def _trim_start_index(messages: Sequence[Message], max_messages: int) -> int: | ||
| """Return the oldest index whose retained suffix contains only complete core groups. | ||
|
|
||
| The raw count boundary advances past every group that crosses it, including linked | ||
| non-contiguous spans. Advancing can cross another linked group, so repeat until the | ||
| boundary is stable. A group larger than the limit is dropped rather than split. | ||
| Pending approval groups are retained whole across saves until their responses arrive, | ||
| even when that temporarily exceeds the limit. | ||
| """ | ||
| count = len(messages) | ||
| if count <= max_messages: | ||
| return 0 | ||
|
|
||
| # group_messages assigns missing message ids to form stable group ids. Shallow | ||
| # copies isolate that top-level mutation without copying the message contents. | ||
| spans = group_messages([copy(message) for message in messages]) | ||
| group_bounds: dict[str, list[int]] = {} | ||
| group_members: dict[str, list[Message]] = {} | ||
| for span in spans: | ||
| group_id = str(span["group_id"]) | ||
| start_index = int(span["start_index"]) | ||
| end_index = int(span["end_index"]) | ||
| bounds = group_bounds.setdefault(group_id, [start_index, end_index]) | ||
| bounds[0] = min(bounds[0], start_index) | ||
| bounds[1] = max(bounds[1], end_index) | ||
| group_members.setdefault(group_id, []).extend(messages[start_index : end_index + 1]) | ||
|
|
||
| start = count - max_messages | ||
| pending_starts = [ | ||
| group_bounds[group_id][0] for group_id, members in group_members.items() if _has_unfinished_approval(members) | ||
| ] | ||
| boundary_limit = count | ||
| if pending_starts: | ||
| # A pending approval may need its original declaration on a later save. | ||
| # Retain the whole suffix containing it, including any overlapping groups. | ||
| protected_start = min(pending_starts) | ||
| while True: | ||
| next_start = min( | ||
| ( | ||
| group_start | ||
| for group_start, group_end in group_bounds.values() | ||
| if group_start < protected_start <= group_end | ||
| ), | ||
| default=protected_start, | ||
| ) | ||
| if next_start == protected_start: | ||
| break | ||
| protected_start = next_start | ||
| start = min(start, protected_start) | ||
| boundary_limit = protected_start | ||
|
|
||
| while start < boundary_limit: | ||
| next_start = start | ||
| for group_start, group_end in group_bounds.values(): | ||
| if group_start < start <= group_end: | ||
| next_start = max(next_start, group_end + 1) | ||
| if next_start == start: | ||
| break | ||
| start = next_start | ||
| return start | ||
|
|
||
|
|
||
| async def _redis_result(value: Awaitable[_T] | _T) -> _T: | ||
| """Await a redis-py command result that is annotated as the sync/async union. | ||
|
|
@@ -316,23 +397,53 @@ async def save_messages( | |
| # history is what clear() is for. | ||
| return | ||
|
|
||
| existing_messages = await self.get_messages(session_id, state=state, **kwargs) | ||
| new_messages = filter_new_messages(existing_messages, messages) | ||
|
|
||
| if not new_messages: | ||
| return | ||
|
|
||
| serialized_messages = [self._serialize_json(msg) for msg in new_messages] | ||
|
|
||
| async with self._redis_client.pipeline(transaction=True) as pipe: | ||
| for serialized in serialized_messages: | ||
| await _redis_result(pipe.rpush(key, serialized)) | ||
| await pipe.execute() | ||
|
|
||
| if self.max_messages is not None: | ||
| current_count: int = await _redis_result(self._redis_client.llen(key)) | ||
| if current_count > self.max_messages: | ||
| await _redis_result(self._redis_client.ltrim(key, -self.max_messages, -1)) | ||
| # Retention trims the oldest messages, and the cut must not split a tool call/result pair. | ||
| # Read, append, and trim are guarded by WATCH so a concurrent save to the same key cannot | ||
| # make this one trim from a stale absolute index and delete the whole session history: if | ||
| # the key changed under us, the transaction aborts and we recompute against the new state. | ||
| last_conflict: WatchError | None = None | ||
| for _ in range(_SAVE_WATCH_RETRIES): | ||
| async with self._redis_client.pipeline(transaction=True) as pipe: | ||
| await _redis_result(pipe.watch(key)) | ||
| # Read through the pipeline's own reserved connection. `WATCH` has | ||
| # already pinned it, and going back out through the parent client would | ||
| # need a second one: a borrowed client with `max_connections=1` would | ||
| # then fail every non-empty save. | ||
| watched = cast("list[str]", await _redis_result(cast("Any", pipe).lrange(key, 0, -1))) | ||
| existing_messages = [Message.from_dict(self._deserialize_json(row)) for row in watched] | ||
| new_messages = filter_new_messages(existing_messages, messages) | ||
| if not new_messages: | ||
| await _redis_result(pipe.unwatch()) | ||
| return | ||
|
|
||
| serialized_messages = [self._serialize_json(msg) for msg in new_messages] | ||
| # ``existing_messages`` plus ``new_messages`` is exactly the list being written | ||
| # (existing entries followed by the appended ones), so its indices line up with the | ||
| # Redis list we are about to trim. | ||
| full_messages: list[Message] = list(existing_messages) + list(new_messages) | ||
| trim_start = -1 | ||
| if self.max_messages is not None and len(full_messages) > self.max_messages: | ||
| trim_start = _trim_start_index(full_messages, self.max_messages) | ||
|
|
||
| pipe.multi() | ||
| for serialized in serialized_messages: | ||
| await _redis_result(pipe.rpush(key, serialized)) | ||
| if trim_start >= 0: | ||
| await _redis_result(pipe.ltrim(key, trim_start, -1)) | ||
| try: | ||
| await pipe.execute() | ||
| except WatchError as exc: | ||
| # The key changed between WATCH and EXEC. Recompute against the new state. | ||
| last_conflict = exc | ||
| continue | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On the eighth
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. {"body": "Fixed in |
||
| return | ||
| # Every attempt aborted. Falling off the end would report success while | ||
| # nothing was persisted, so surface the conflict that defeated the last one. | ||
| raise ( | ||
| last_conflict | ||
| if last_conflict is not None | ||
| else RuntimeError(f"Redis history save for {key!r} did not persist after {_SAVE_WATCH_RETRIES} attempts") | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _serialize_json(message: Message) -> str: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yufeng He (@he-yufeng) Approving a tool batch one item at a time can leave results without their calls — This is not an ID-matching problem: an earlier save can already have deleted the original
function_calland its canonicalContent.id. When the remaining approvals finish, their results are saved without those declarations, and the next stateless request sends invalid tool history. Please keep the pending approval group intact across saves, or omit results whose calls were trimmed, and add a multi-save approval regression.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implemented in
4b37cba3e, with current main merged in986e477ef.Shared grouping now links a separately stored approval request to its original function call without consuming the declaration before the later tool result is paired. Redis retains groups with unanswered approval requests across saves, even when the pending group predates the raw retention window. Once the responses arrive, normal complete-group trimming resumes.
The new three-save regression approves two calls separately. The old implementation retained the second result beside
doneafter its declaration had been trimmed; the new implementation retains only the completedonemessage, and the later real OpenAIstore=Falserequest preparation passes. A second regression proves that a pending group is not trimmed when it lies entirely before the raw window: the old boundary was 3, while the new boundary is 0. The existing responded-approval trimming contract is unchanged.On the merged head, compaction is 161/161, Redis is 349 passed and 58 skipped, and the OpenAI chat-client file is 561 passed and 28 skipped. Ruff passes for core and Redis. Redis Pyright, mypy, pyrefly, ty, and zuban all pass; core mypy, ty, and zuban also pass. No live Redis or OpenAI network run is claimed.