Repository navigation
fix(message-history): return the whole session from the messages property - #752
Conversation
…erty SemanticMessageHistory.messages built its FilterQuery without num_results, so the FilterQuery default of 10 applied and a longer session came back as its oldest 10 messages. MessageHistory.messages hard-coded num_results=1000 and stopped there. Both are documented as returning the full message history. Page through the session with SearchIndex.paginate instead, and add tests that fail on the old code (12 and 25 semantic messages, 1005 standard messages) plus controls for short and empty sessions, session scope and tool fields.
vishal-bala
left a comment
There was a problem hiding this comment.
Thanks for your contribution and for finding this! Just a couple comments about the approach and then we can get this going
| messages = self._index.query(query) | ||
| # Page through the whole session: a single query stops at num_results. | ||
| messages = [ | ||
| m for page in self._index.paginate(query, page_size=1000) for m in page |
There was a problem hiding this comment.
paginate re-sorts the whole session on every page, so the cost of a read grows with the square of the session length. On Redis 8.4, .messages took 1.7 s for a 50k-message session, against 0.3 s for one query. Sizing a single query from count() takes the same two round trips (paginate always ends with an empty page) and sorts once:
query = FilterQuery(
filter_expression=self._default_session_filter,
return_fields=return_fields,
num_results=self.count(),
)
query.sort_by(TIMESTAMP_FIELD_NAME, asc=True)
messages = self._index.query(query)It also sidesteps paginate's unique-sort-field requirement, which timestamps don't meet.
| messages = self._index.query(query) | ||
| # Page through the whole session: a single query stops at num_results. | ||
| messages = [ | ||
| m for page in self._index.paginate(query, page_size=1000) for m in page |
| ) | ||
| query.sort_by(TIMESTAMP_FIELD_NAME, asc=True) | ||
| messages = self._index.query(query) | ||
| # Page through the whole session: a single query stops at num_results. |
There was a problem hiding this comment.
Could the messages docstring (here and in semantic_history.py) say this returns every message in the current session, oldest first, with no bound, and point to get_recent(top_k=...) for a bounded read?
|
|
||
|
|
||
| @pytest.fixture | ||
| def stub_semantic_history(app_name, client): |
There was a problem hiding this comment.
The existing semantic_history fixture already runs in CI and takes about 0.1 s for a 12-message test. Could past_10 use it with @pytest.mark.requires_hf, so this fixture and the CustomVectorizer import can go?
| assert standard_history.messages == messages | ||
|
|
||
|
|
||
| def test_standard_messages_property_scope(standard_history): |
There was a problem hiding this comment.
This passes without the fix, and so does the semantic one. It still covers a real gap, though, since nothing else checks session scoping. Could you fold it into past_1000 with a single add_messages(..., session_tag="other_session") line and drop both standalone tests?
| assert stub_semantic_history.messages == messages | ||
|
|
||
|
|
||
| def test_semantic_messages_property_matches_get_recent(stub_semantic_history): |
There was a problem hiding this comment.
past_10 already covers this, so let's drop it. The same goes for up_to_10 and keeps_tool_fields: both pass without the fix and repeat what test_semantic_messages_property already checks.
…r review Replace SearchIndex.paginate in both messages properties with a single FilterQuery sized by count(). paginate re-sorted the whole session for every page and needs a unique sort field, which timestamps are not. An empty session gives num_results=0 and returns []. Say in both docstrings that messages returns every message in the current session, oldest first, with no bound, and point to get_recent(top_k=...) for a bounded read. Tests: the 12-message semantic test uses the existing semantic_history fixture (requires_hf), so the stub fixture and CustomVectorizer import go. Session scoping is folded into the 1005-message test. Drop the get_recent comparison, which the 12-message test covers, and the standalone scope, up-to-10 and tool-field tests, which pass without the fix.
|
Thanks for the careful review, Vishal. I pushed a commit that addresses each point:
sentence-transformers isn't installed on my machine, so I ran the semantic test with a local stand-in vectorizer; the real |
Problem
SemanticMessageHistory.messagesandMessageHistory.messagesare both documented as "Returns the full message history." Neither returns it once a session gets long enough.SemanticMessageHistory.messagesbuilds itsFilterQuerywithoutnum_results, so theFilterQuerydefault of 10 applies. A session with 12 messages comes back as its oldest 10.MessageHistory.messagessetsnum_results=1000and stops there.Both sort by timestamp ascending, so the messages that go missing are the most recent ones. Nothing raises or logs, and
count()still reports the real total.An app that rebuilds an LLM context, exports a transcript or summarises a conversation from
messagesloses everything after the 10th (semantic) or 1,000th (standard) message.get_recent(top_k=...)is not affected, because it passestop_kthrough as the query size.Reproduction
Run against a local Redis 8.10.2 with the Query Engine on port 6390. The vectorizer is a stand-in, which is fine here because
messagesnever uses vectors.On
main:With this change:
That is the full output of each run, nothing trimmed.
Change
Both properties now run one
FilterQuerysized fromcount()and sorted by timestamp ascending, as @vishal-bala suggested in review (the first version paged withpaginate, which re-sorts the session on every page). It is +12/-3 across the two files, docstrings included. The session filter and the sort are unchanged.The docstrings now say
messagesreturns every message in the current session, oldest first, with no bound, and point toget_recent(top_k=...)for a bounded read. An empty session givesnum_results=0and returns[].The intended behaviour change is that a long session now returns every message, so the list can be large. Each read is 2
FT.SEARCHcalls (the count, then the query) for both 12 and 1,005 messages, counted withINFO commandstats.Limits worth knowing:
count()and the query is not included in that read.search-max-search-results(1,000,000 here) now raisesSEARCH_LIMIT_OVERinstead of coming back short.messagesontoget_recentis left alone. This keeps the current API and only fixes the size.Verification
Two new tests in
tests/integration/test_message_history.py, against a real Redis:test_standard_messages_property_past_1000: 1,005 messages, plus 15 insession_tag="other_session"that must not appear.test_semantic_messages_property_past_10: 12 messages, using the existingsemantic_historyfixture and markedrequires_hf.Both fail on
main(the standard one comes back 5 short, the semantic one 2 short) and pass with this change, 3 runs each way.Local setup: Redis 8.10.2 with
search-workers 0, Python 3.12.14 and theuv.lockpins; a small local pytest plugin pointed the compose fixture at that server, and is not part of this PR. sentence-transformers is not installed here, so for the semantic test a local stand-in replacedHFTextVectorizerwith a fixed-size vectorizer (messagesnever uses vectors). The realrequires_hfpath will only run in CI.On
tests/integration/test_message_history.pywithout the stand-in, the only differences betweenmainand this branch are the two new tests; the remaining failures and errors are the same both ways and come from the missing sentence-transformers. isort, black 26.5.1 and mypy ("no issues found in 120 source files") are clean on the changed files.This is a bug fix, so the label would be
auto:patchper CONTRIBUTING.md. A pull request from a fork cannot set labels.A search of this repo's issues and PRs found no existing report of the cap.
This PR was written with AI assistance (Claude Code). Everything quoted above was run before filing.
Note
Medium Risk
Behavior change for long sessions (larger reads and two Redis searches per access); very large sessions may hit server search result limits instead of silent truncation.
Overview
Fixes a silent truncation bug where
MessageHistory.messagesandSemanticMessageHistory.messagesdid not return the full session. Standard history capped at 1,000 results; semantic history usedFilterQuery's default of 10, so the newest messages were dropped whilecount()still reported the true total.Both properties now set
num_results=self.count()on the same timestamp-ascendingFilterQuery, so reads return every message in the current session (oldest first). Docstrings clarify thatmessagesis unbounded and point callers toget_recent(top_k=...)for a limited window.Integration tests cover 1,005 standard messages (with a separate session tag excluded) and 12 semantic messages.
Reviewed by Cursor Bugbot for commit 7188c45. Bugbot is set up for automated code reviews on this repo. Configure here.