Repository navigation
fix: [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager (#4027) - #4066
Conversation
|
@strandly-the-agent then give this PR an initial review, catch bugs and make sure code style consistent with exisiting codebase. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Request changes. The bug is genuinely fixed for the case #4027 describes — but the fix reconstructs the pinned head from the constructor argument rather than persisting it, and two verified silent-corruption paths follow from that.
- 🔴 Restore reattaches the wrong number of messages whenever the live pinned head isn't exactly
pin_first: a first turn that used a tool (ordinary), orpin_firstchanged between runs. Silent, and one variant fabricates a fake tool result. - 🔴 After a restore the head is no longer pin-protected, so the next compaction summarizes the pinned messages away. The PR fixes the first restore and moves the loss one step later, where it looks correct on resume.
- 🟡 One new store-destruction path (empty tail → append restarts at id 0), and 🟡 the new gate is untested — it can be replaced with
if True:and all 172 tests still pass.
All three code findings share one fix: persist the pinned-head count instead of the boolean (verified inside the PR's own route: issue repro still passes, all drift cases correct). Migration cost is identical to the boolean today and unrecoverable later — so the key's shape is the one thing worth settling before merge, not after.
Style: ruff check and ruff format --check are clean on all four files. Process: this changes a customer-facing persisted-state contract and likely needs the api/needs-review label (details below). Solid work on a real bug — this is a review for a human to arbitrate, not a gate.
Breakdown — evidence ledger, scope verdict, questions, reading order, appendix
Evidence ledger — branch pr-4066 @ 565a9de, base 9062527
- ✅ Issue #4027's own e2e repro (FileSessionManager + stub model, incl. a two-compactions-then-resume variant): 3 passed on head; the same file fails on
main@3f1e99b. - ✅ Scoped suites (
test_summarizing_conversation_manager.py,tests/strands/session/,test_pin_message.py): 251 passed on head. Test-quality pass ran 320 scoped, 13 mutants × 172 tests → 3 survivors. - ✅
ruff check→ "All checks passed!";ruff format --check→ "4 files already formatted". - 🔴 Post-restore compaction eats the pinned head (probe, public File path, head and base).
- 🔴 Config drift:
pin_first2→3 resurrects a summarized message and drops a live one; 2→1 loses a pinned message; →Nonerestores the old bug. Silent in all cases. - 🔴 Tool-pair straddle: live
RES(tu1, success, "REAL RESULT")→ restoredRES(tu1, error, "Tool was interrupted."), plus one summarized message resurrected. One WARNING log, no error. - 🔴 Empty-tail restore overwrote stored id 0 on a real File store; base appends at id 3.
- ✅ Checked, clean: forward compat (state written by this PR restored by pre-PR code raises nothing, extra key ignored); snapshot round-trip (
take_snapshot/load_snapshotis deepcopy-based, no offset arithmetic — the new flag even prevents base's mis-pin of the summary); adjacent user/user after restore matches live exactly;pin_first >= stored countunreachable as a successful compaction (the "all pinned" raise fires first); no signature break for community subclasses. - 🔴 Not run:
mypy,complexipy; no probe for the AgentCore/S3 double round-trip, theunpinhalf of the arbitrary-pin case, or multi-toolUse-block messages straddling the boundary.
Scope (vs #4027)
"Closes #4027" is earned on the letter — the issue performs one compaction, and both of its failure modes are fixed and stay fixed. It is not earned on the title's promise that pin_first works with File/S3 sessions generally: the post-restore compaction loss means the pin lasts exactly one restore. Every hunk is in-contract or supporting — no unrelated riders. Notable: the pin_first_applied state key is route-specific, not contract-required (verified — swapping the gate for removed_message_count > 0 and dropping both state hunks still passes the issue repro); the _summary_message/flag rollbacks are riders that are dead in practice; the try wraps the whole body where a narrow guard around summary generation would do. Recommendation: keep "Closes #4027" only if the persisted-count fix lands in-PR, since the key's shape can't be revised later.
Pipeline disclosure
Triage → context build → specialist passes → aggregation, each an independent fresh-context pass. The correctness and adversarial passes timed out and were re-run merged/narrowed at the default tier — the four uncovered items in the ledger are the visible cost. The api-design pass also timed out and was re-run narrowed. Test-quality, issue-alignment and context-build completed normally; llm-context and docs-accuracy were routed out (no model-facing text, no docs changed). One pass finding (except Exception missing CancelledError) was downgraded during aggregation after checking the code — see Q3.
Style consistency (the specific ask)
Lint is clean; everything below is convention-level and non-blocking: five issue-linking/narrative comment blocks were added in src, where AGENTS.md:57 puts change-explaining prose in the PR description (the issue links in the tests are explicitly sanctioned by the same line) — the durable part, "bookkeeping is unchanged when summarization fails", belongs in reduce_context's existing Raises:/docstring instead. _async_gen_raising duplicates the file's existing error-stream helper (_mock_model_stream_error, used 4×) against docs/TESTING.md:23; narrative "without the fix" comments in tests run against TESTING.md:82; one new test near-duplicates an existing one (TESTING.md:71). The new try adds a nesting level on a repo that labels PRs by cognitive complexity. mypy/complexipy were not run here — the author reports hatch run prepare clean.
Questions
Blocking (the answer changes the merged shape):
- Is the persisted key meant to be a boolean? Once
trueis in customer stores there's no schema version to migrate it, and it can't later be mapped to a count without guessing. Persisting the count costs the same today. - Should the conversation manager own its restore contract instead of the session manager reading its private attribute? A public
pinned_head_counton the base class, carried inget_state/restore_from_session, mirrorsremoved_message_count, is visible to mypy, and closesSlidingWindowConversationManager(#4087) for free — both subclasses already chainsuper(). Extending the prepend contract doesn't work (the manager has no repository handle), so the real choice is "privategetattr" vs "one public attribute".
Non-blocking:
3. Would reordering beat the try/except? Moving every self.* mutation to after summary generation — the only fallible call — makes the method atomic by construction: no snapshot variables, no rollback, no extra nesting. Pinning must stay before the partition, which is benign since the array is only reassigned on success. (A pass flagged except Exception missing CancelledError; downgraded here because run_async isolates the call in its own thread and no public trigger could be named — second opinions welcome.)
4. Is the head-only limitation worth documenting in this PR? Neither a boolean nor a count survives pins placed elsewhere (the model-facing pin_context tool; user-set metadata.custom.pinned) — filed as #4088; only per-message marker persistence closes it, which is real work and out of scope here.
5. api/needs-review label — the persisted state dict is JSON in customer stores and part of the snapshot preset, unversioned; the private read makes two attributes a de facto interface for every manager implementation. The label workflow only fires once the label is present, so nothing catches this automatically.
6. PR description accuracy (cosmetic): "3 regression tests for rollback" is 2 of 3; bug 2 — the counter bumped before generation, the actual data-loss defect — doesn't appear under Root cause and is described as retry hygiene; "89 tests passed" reproduces exactly.
Reading order (for human reviewers)
repository_session_manager.py:233-256— the restore arithmetic; all the risk is in these 24 lines (where the pinned-head size comes from, how the tail offset is derived).summarizing_conversation_manager.py:96-108— the persisted schema; the one thing this PR can't revise later.summarizing_conversation_manager.py:182-222— the rollback; correct as written, the question is only whether reordering removes the need for it.- Tests last, read against step 1:
test_repository_session_manager.py:1224-1307, thentest_summarizing_conversation_manager.py:939-1008. Every one hand-seeds the state it asserts on, which is what let the post-restore case through.
Appendix — non-blocking (10)
- ⚪ Five issue-linking/narrative comment blocks in src (
summarizing_conversation_manager.py:96-98,:182-183,:216-218;repository_session_manager.py:233-234,:251-252) vsAGENTS.md:57. - ⚪
_async_gen_raisingduplicates_mock_model_stream_error(same file, used 4×); alsoif False: yield Nonewhere the existing helper is cleaner. - ⚪ Narrative "without the fix" comments in tests (
test_repository_session_manager.py:1227-1229,test_summarizing:922). - ⚪
test_initialize_restores_summary_without_pin_firstnear-duplicates the existing offset-restore test at:149-186; fold the exact-transcript assert into that one. - ⚪ The rollback comment (
:216-218) overstates its guarantee: leaving live markers is safe in isolation, but they're the only pin state anywhere and don't survive the process. - ⚪ Rolling back the flag while leaving live markers set creates two sources of truth for "is this pinned" — harmless today, verified.
- 🟡 (unprobed) Restore now makes two
list_messagescalls: S3 re-runs a full paginate per call, andAgentCoreMemorySessionManager.list_messagesreturns[]on any exception instead of raising — a transient failure of the pinned-head call would silently drop the head.limitalso becomes load-bearing for third-party repositories, which nothing enforces. Worth a follow-up issue (or a "repositories must raise, not return[]" contract note) if the two-call shape survives review. - ⚪ Community subclasses: no signature break; exposure is behavioural only.
- ⚪ No TypeScript port needed — TS session persistence is whole-array, structurally immune to offset arithmetic.
- Pre-existing, filed separately (not this PR's problem):
SlidingWindowConversationManagerhas the same restore defect → #4087; pins outside the leading head never survive restore → #4088.
JackYPCOnline
left a comment
There was a problem hiding this comment.
see initial agent's review feedback.
Address review feedback on PR strands-agents#4066: Finding 1: Persist pinned_head_count (the actual count of preserved messages) in the base ConversationManager class rather than a boolean. This fixes: - pin_first changed between runs (different count → wrong restore) - pinned head ends mid tool-pair (count differs from configured pin_first) Finding 2: Re-apply pin markers to restored pinned messages so the next compaction respects them. Without this, the first restore worked but the next compaction summarized away the 'pinned' messages. Finding 3: When the tail comes back empty but the store is non-empty, seed the append cursor from the last stored message to prevent overwrites. Finding 4: Add tests for legacy state migration (missing pinned_head_count defaults to 0) and port the issue strands-agents#4027 end-to-end File-backed repro. Finding 5: Replace weak retry test with one that induces failure then succeeds, verifying exact transcript and removed_message_count. Style: Make _summarize_oldest atomic by reordering mutations after summary generation succeeds, removing the try/rollback pattern.
|
Addressed in 5eebc3f + b84c6df:
|
|
@strandly-the-agent please re-review it. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Approve. All five findings from my initial review are fixed — each verified by re-running the original failing probe cold against head 88dada1, not from the commit messages. The fixes also took the harder, better route: pinned_head_count is now a public base-class attribute and the rollback was replaced by reordering, which makes compaction atomic by construction.
Per-finding status:
- 🔴 restore used the constructor arg → fixed — persisted
pinned_head_count; config-drift probe now matches forpin_first=3/1/None, and the tool-pair straddle case is fixed too (count captures the partner-protected extra: live head of 3 restores as 3, the real"REAL RESULT"toolResult survives instead of a fabricated error). - 🔴 restored head lost pin protection → fixed — markers re-applied on restore; probe: pin survives the next compaction (
question number 1/answer 1still at head). - 🟡 empty tail → append restarted at id 0 → fixed — cursor seeded from the store; probe: append lands at id 3, id 0 intact.
- 🟡 untested gate / no legacy-state test → fixed — legacy-dict test, persisted-count-vs-constructor test, empty-tail id test, and the issue's e2e File repro are all in the suite now.
- 🟡 retry test tested nothing → fixed — real fail-then-succeed with exact
==assertions; the wrong "assistant" comment is gone.
Two new nits (non-blocking) and one process note in the breakdown. Not run: mypy/complexipy.
Breakdown — evidence ledger, review shape, nits
Evidence ledger — head 88dada1 (fix commits 5eebc3f + b84c6df; the main-merge brought zero changes to the touched files — verified empty diff)
- ✅ Issue #4027 e2e repro (incl. two-compactions variant): 3 passed (0.81s).
- ✅ Scoped suites (
test_summarizing_conversation_manager.py,tests/strands/session/,test_pin_message.py,test_conversation_manager.py): 326 passed. - ✅
ruff check+ruff format --checkon all 5 changed files: clean. - ✅ Config drift probe: restore with
pin_first=3,1,Noneafter compacting at2→ all match the live transcript (previously all three silently corrupted). - ✅ Tool-pair straddle probe: first turn uses a tool,
pin_first=2→ compaction recordspinned_head_count=3, restore reproduces the live transcript exactly, real toolResult preserved (previously replaced by a fabricated "Tool was interrupted."). - ✅ Post-restore compaction probe: restored head carries
pinned=Truemarkers,pinned_head_count=2, and the pinned messages survive the next compaction (previously summarized away). - ✅ Empty-tail probe: restored
['m0','m1','SUMMARY'], next append → store[(0,m0),(1,m1),(2,m2),(3,NEW-TURN)](previously(0,'NEW-TURN')destroyed the first stored message). - ✅ Legacy pre-PR state dict (no
pinned_head_countkey): restores without exception, count 0 → documented pre-PR behaviour; and the booleanpin_first_appliedfrom the previous PR revision is simply ignored (never released, no migration owed). - ✅ Atomicity: try/except rollback replaced by commit-after-generation — no bookkeeping mutated on any failure path, which also moots the
CancelledErrorquestion from the first review. - 🔴 Not run:
mypy,complexipy.
Review shape (disclosure)
This was a follow-up review run directly by me (no fresh specialist passes): the delta only addresses my previous findings plus their tests, so I verified each fix against the diff, re-ran every original probe cold, and attacked the fixes themselves (merge-commit contamination, legacy/forward state compat, straddle-over-count semantics, second-compaction behaviour after restore). The initial review was the full multi-pass pipeline.
New nits (⚪, non-blocking)
_pin_message_inline(repository_session_manager.py:28-38) is a verbatim copy ofpin_message(compression/pin_message.py:90-104), and the import works from the session module (no cycle — verified). If the marker format ever changes, these drift independently; suggest importing instead.- The new public
pinned_head_countisn't documented in the base class'sAttributes:docstring (conversation_manager.py:77-80documentsremoved_message_countright above it); one sentence there — "number of leading store messages still live in the transcript after compaction" — makes the restore contract discoverable. - Minor observation, no action needed: restore marks partner-protected head messages (e.g. a toolResult pinned only via its partner) as explicitly pinned. Behaviourally equivalent for eviction protection.
Process note
The api/needs-review label question from the first review is now stronger, not weaker: the fix adds a public attribute to ConversationManager (a customer-facing base class) and a persisted key that every File/S3 session and snapshot will carry. That's the good version of the change — it just makes the label squarely applicable. Maintainer's call.
Follow-ups unchanged
#4087 (SlidingWindow pin_first restore) is untouched by this PR and now trivially fixable on this foundation — set pinned_head_count in its trim path. #4088 (non-head pins) remains the known limitation of any positional scheme, including this one.
|
Synced the branch with Re-verified against the merged result rather than the pre-merge head:
The fix still holds after main's @JackYPCOnline the standing request-changes review is now the only thing on the merge gate (the bot's re-review approved on 09-03). Would you mind taking another look? |
|
Re-checking the blocker here, because the review state and the code have diverged. The
The branch head is now Re-verified on the current head rather than the old one: Separate question I'd rather ask than keep nudging: on #4027 @lizradway noted that Conversation Managers aren't integrated with Session Management and that Conversation Manager is being deprecated in favour of Context Manager, which does support context-level session management. I can see context-py's session manager integration landed in #4254 (merged 09-10). Is this |
|
@strandly-the-agent re-review the correctness and ensure no regression. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Approve on 1ae3470. Correctness holds and nothing regresses. The only delta since my 09-03 approval is the main sync merge, and I inspected its conflict resolution directly (git show --remerge-diff): the fix was correctly folded into main's LocalAgent refactor — pinned_head_count now sits inside the new isinstance(agent, Agent) guard, the tail offset is pinned_head_count + offset, and the append-cursor seeding keys off the same offset. Non-Agent local agents get pinned_head_count = 0, i.e. exactly main's behaviour.
All five original findings re-verified still fixed by cold probe re-runs on this head (drift 3/1/None, tool-pair straddle with the real toolResult preserved, pin survival through a post-restore compaction, empty-tail append landing at id 3 with id 0 intact, legacy state dict restoring without exception). Regression baseline: the same scoped suites pass on both this head (344) and current origin/main (343 — the delta is the PR's own tests); the issue repro passes here and fails on main; a trial merge into current main (f4567bf) is clean with 355 passed. ruff check/format clean.
On the scope question AnnasMazhar raised: that's a maintainer call, but the facts on current main are that conversation_manager= is still an accepted Agent parameter with no deprecation warnings in conversation_manager/*.py, pin_first is still documented in the user guide, ContextManager is still exported under strands.experimental, and File/S3 remain the only session path for multi-agent and Bidi — so until a deprecation actually lands this guards shipped surface. Whether to merge now or re-target is for lizradway/JackYPCOnline.
Evidence ledger — head 1ae3470, merge-base 08ed4cf, main f4567bf
Conflict resolution (the only code delta since 09-03)
git show --remerge-diff 1ae3470: two conflicted files, both resolved correctly.repository_session_manager.py:class RepositorySessionManager(SessionManager[LocalAgent])taken from main;pinned_head_count = 0default + assignment inside main'sisinstance(agent, Agent)block (:253-260);offset=pinned_head_count + offset(:281);elif pinned_head_count > 0 or offset > 0(:288). Test file conflict was marker-only.- Grepped
src/strandsfor everylist_messages(/removed_message_count/pinned_head_countreader: the guarded block is the only restore path with offset arithmetic; main added no parallel path;SnapshotSessionManagerstill never reads the counter.
Probes (all PASS, /tmp/probes/battery.py, run cold on this head)
- restore == live after compaction; restored head re-pinned (
[True, True],pinned_head_count=2); pinned head survives the next compaction. - config drift: restore with
pin_first=3,1,Noneall match the live transcript. - legacy pre-PR state dict (no
pinned_head_countkey): restores without exception, count 0 → documented pre-PR behaviour. - tool-pair straddle (
pin_first=2, first turn used a tool):pinned_head_count=3, restore == live, real"REAL RESULT"toolResult preserved. - empty tail: store
[(0,m0),(1,m1),(2,m2),(3,NEW-TURN)]— id 0 intact, append at 3.
Suites
- Issue #4027 e2e repro: 3 passed on head (0.87s); 3 failed on
origin/main(bug present there, as expected). - Scoped (
test_summarizing_conversation_manager.py,tests/strands/session/,test_pin_message.py,test_conversation_manager.py): 344 passed on head; 343 passed onorigin/main— no failures on either side. - Trial merge
pr-4066+origin/main@f4567bf: auto-merges clean; same scoped suites 355 passed; repro 3 passed. ruff check→ All checks passed;ruff format --check→ 5 files already formatted.- Not run:
mypy,complexipy, full non-scoped suite.
Review shape
Follow-up run directly by me (no fresh specialist passes): the delta is a merge commit only, so the work was verifying the conflict resolution line by line, re-running every original probe, and establishing the main-side regression baseline.
Still open from the 09-03 appendix (non-blocking, unchanged)
_pin_message_inline duplicates pin_message (importable, no cycle); pinned_head_count missing from the base Attributes: docstring; api/needs-review label applicability (public base-class attribute + persisted key). Follow-ups #4087 / #4088 unchanged.
Merge gate note
The standing CHANGES_REQUESTED reviews are both on 565a9de (pre-fix); this approval and the 09-03 one are on the fixed heads.
|
Synced with The conflict was a single hunk in session_messages = self.session_repository.list_messages(
session_id=self.session_id,
agent_id=agent.agent_id,
offset=pinned_head_count + offset, # this PR: skip the still-live pinned head
)
self._held_records[agent.agent_id] = list(session_messages) # main: alias records for redaction
# When the tail comes back empty but the store is not, seed the append cursor ...
if len(session_messages) > 0:Re-verified on the merged head (
Review state note: the standing |
|
Rework pushed in 06ee72c — two test-only commits (+36 / +25 lines across the two test files, no production code changed in this push). 🟡 the new gate is untested — agreed, it was vacuous. The reworked tests now fail when the fix is absent. Experiment: revert only the non-test files of 5eebc3f ( So the assertion 🔴 persist the count rather than the boolean / re-apply pin protection after restore — that is a7ede89 ( Not touched by this push, so it is clear what you are reviewing: |
|
Rework pushed in Blocking 1 — persist a count, not a boolean: PASS The persisted state is now the count ( Blocking 2 — public contract instead of a private attribute read: PASS
Non-blocking 3 — atomic by construction: PASS Reordering did remove the rollback entirely. In Style items: PASS This includes folding Evidence
I also corrected the description per Q6: "3 regression tests for rollback" is 2, and the pre-generation counter bump is now stated under Root cause as the data-loss defect it is. |
|
@strandly-the-agent ignore previous conversation, re-review this PR |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Request changes — one blocker, in the newly added scope. Fresh review of head 0b1b92e2 against merge-base 15da9dc. Everything the PR fixed for SummarizingConversationManager still holds under cold re-runs (all 10 probes, issue repro, 432 scoped tests, clean trial merge into current main), and the reworked tests now have teeth (10 of them fail when run against the pre-fix source). The base-class pinned_head_count and the commit-after-generation ordering are the right shape.
The blocker is the new SlidingWindowConversationManager change that claims to resolve #4087: it records pinned_head_count = min(pin_first, len) (sliding_window_conversation_manager.py:200), but trimming protects the tool-pair partner of a pinned message too (is_pinned), so with pin_first=2 and a first turn that used a tool the live head is 3 messages and restore reattaches 2. Verified on a real FileSessionManager: the real toolResult comes back as a fabricated "Tool was interrupted." error and a trimmed message is resurrected — the same straddle defect the summarizing side fixed by counting len(protected_to_preserve). The fix is one expression (count the protected prefix); I verified it passes every sliding probe and the full 432-test scoped suite. Details and a suggestion block inline.
🟡 Also: #4087 is claimed resolved but no test covers sliding-window restore with pin_first (plain or straddle). ⚪ Two 09-03 nits remain (_pin_message_inline duplicating pin_message; pinned_head_count missing from the base Attributes: docstring). Not verified here: mypy (my env's 2.3.1 crashes internally — author reports rc=0).
Evidence ledger, regression baseline, what was checked
Head 0b1b92e2 · merge-base 15da9dc · main 226a0f2
- ✅ Issue #4027 e2e repro (incl. two-compaction variant): 3 passed.
- ✅ Scoped suites (
test_summarizing_conversation_manager.py,tests/strands/session/,test_pin_message.py,test_conversation_manager.py,agent/conversation_manager/): 432 passed on head; 419 passed onorigin/main; trial merge into current main auto-merges clean, 430 passed (the −2 vs head are main-side bidi/hook test renames — verified by collect-only diff, unrelated to this PR). - ✅ Teeth: PR test files run against merge-base
src→ 10 failed, 94 passed (the fix is load-bearing for 10 tests). - ✅
ruff check/ruff format --checkclean (11 files). ⚠️ mypy: internal crash in my environment (mypy 2.3.1) — not a PR finding, just unverified by me.
Summarizing-manager probes (all PASS, cold on head)
restore == live; restored head re-pinned (count=2); pinned head survives post-restore compaction; config drift pin_first=3/1/None all match; legacy state dict (no pinned_head_count) restores, count 0; tool-pair straddle count=3, real toolResult preserved; empty tail → store [(0,m0),(1,m1),(2,m2),(3,NEW-TURN)].
Sliding-window probes (new scope)
| case | result |
|---|---|
| plain head (#4087's own case) | ✅ restore == live, count=2 removed=4 |
window_size=0 |
✅ |
| restore → trim again → restore | ✅ head kept both times |
| tool-pair straddle | 🔴 live 7 msgs incl. toolResult(tu1, success, REAL RESULT); restored 8 msgs with toolResult(tu1, error, "Tool was interrupted.") + resurrected a1; count=2 |
| straddle with the one-line fix | ✅ restore == live; all sliding probes pass; 432 scoped tests pass |
Reachability of the blocker
Agent(tools=[…], conversation_manager=SlidingWindowConversationManager(pin_first=2), session_manager=FileSessionManager(…)), first turn calls a tool, conversation runs past window_size, process restarts. Pre-PR, this path already lost the head (#4087); the PR changes the failure into a fabricated tool outcome and claims the issue resolved — which is why it's a blocker rather than an appendix line.
Review shape
Fresh full review by me on the current head (maintainer asked to disregard prior context). Delta since my 09-22 approval: main sync (a7ede89), two test-teeth commits, and 0b1b92e2 which is the new production change (sliding window + base-class carry). Probes and suites were re-run cold rather than trusted from the PR body.
| if self.pin_first and self.pinned_head_count == 0: | ||
| apply_pin_first(messages, self.pin_first) | ||
| self._pin_first_applied = True | ||
| self.pinned_head_count = min(self.pin_first, len(messages)) |
There was a problem hiding this comment.
🔴 This counts pin_first, but trimming protects more than pin_first — so restore reattaches too few messages and fabricates a tool result.
is_pinned protects a message whose adjacent tool-pair partner is pinned (compression/pin_message.py:28-55), and the trim below honours that. With pin_first=2 and a first turn that used a tool, three messages survive trimming but pinned_head_count says 2; on restore the session manager reattaches 2 and offsets the tail by 2 + removed. Verified on a real FileSessionManager:
live: use-the-tool, toolUse(tu1), toolResult(tu1, success, "REAL RESULT"), u2, a2, u3, a3
restored: use-the-tool, toolUse(tu1), toolResult(tu1, error, "Tool was interrupted."), a1, u2, a2, u3, a3
The real result is gone (_fix_broken_tool_use synthesises the error), and a trimmed message is back. Same defect the summarizing side avoids by recording len(protected_to_preserve). Reaches any SlidingWindowConversationManager(pin_first=N) user on File/S3 whose pinned head ends on a toolUse.
Suggestion (verified: all sliding probes pass, 432 scoped tests pass):
| self.pinned_head_count = min(self.pin_first, len(messages)) | |
| self.pinned_head_count = next( | |
| (index for index in range(len(messages)) if not is_pinned(messages, index)), len(messages) | |
| ) |
Plus one regression test: sliding-window restore with pin_first=2 where message 1 is a toolUse and message 2 its toolResult — none of the new tests cover sliding-window restore at all, though the PR now claims #4087.
JackYPCOnline
left a comment
There was a problem hiding this comment.
Need more refinement
|
Heads-up on the timeline, because here the review state and the code have diverged.
Your review on 2026-10-01 landed on the post-fix head ( 🔴 Blocker — if self.pin_first and self.pinned_head_count == 0:
apply_pin_first(messages, self.pin_first)
# is_pinned also protects a pinned message's tool-pair partner, so the protected head can
# be longer than pin_first; restore reattaches exactly this many stored messages.
self.pinned_head_count = count_protected_prefix(messages)
🟡 No test covered sliding-window restore with
Not vacuous: run against the pre-fix source ( ⚪ ⚪ Verification on head That is your 432 plus the three new tests. Lint on the same tree: Not verified by me: @JackYPCOnline — if the refinement you want is beyond the four above, naming the file (or line) is enough and I'll turn it around the same day. |
|
Status check at the current head, because the newest review state is pinned to an older sha.
@JackYPCOnline - please re-review against |
|
Status at head
Ask: please name the specific gap to refine, or re-run @strandly-the-agent against |
|
Thanks for the thorough review. Both blocking questions are now answered on the branch. Q1: the persisted key is a count, not a boolean. Q2: the manager owns its restore contract. The findings, one by one:
Verification: 332 passed on Python 3.12 across Could you take another look? |
|
Status at head
On the review: the newest If you can name the remaining gap — even as one line ("the pin semantics are still wrong for X") — I will fix it on this branch. Otherwise, could a reviewer take the current head as the review target so the decision is not pinned to a sha five commits behind? |
…ds-agents#4027) Two regressions from SummarizingConversationManager(pin_first=N): 1. After compaction, FileSessionManager/S3SessionManager restore skipped the pinned head: list_messages(offset=removed_message_count) used the pre-summarization offset, which now coincides with the still-live pinned messages. The resumed agent lost the first N messages (test_a_resumed_conversation_is_the_one_the_agent_had). 2. A failing summary generation mutated bookkeeping (removed_message_count, _pin_first_applied, _summary_message) before raising, so the next compaction attempt saw a corrupted state (test_a_resumed_conversation_ keeps_what_no_summary_replaced). Fix: * _pin_first_applied now round-trips through get_state/restore_from_session. * apply_management snapshots the three mutable fields and rolls back on any exception so a failed summary leaves the agent untouched. * RepositorySessionManager.initialize re-fetches the pinned head from the session repository and prepends it to the restored transcript; the list_messages offset shifts by +pin_first so the tail does not overlap with the reattached head. Regression tests cover both bugs end-to-end (roll-back, pinned-restore, no-pin-baseline, no-summary-yet). 89/89 tests pass in strands-py/tests/strands/{agent/test_summarizing_conversation_manager, session/test_repository_session_manager}.py. Refs: strands-agents#4027 style: apply ruff formatting
Address review feedback on PR strands-agents#4066: Finding 1: Persist pinned_head_count (the actual count of preserved messages) in the base ConversationManager class rather than a boolean. This fixes: - pin_first changed between runs (different count → wrong restore) - pinned head ends mid tool-pair (count differs from configured pin_first) Finding 2: Re-apply pin markers to restored pinned messages so the next compaction respects them. Without this, the first restore worked but the next compaction summarized away the 'pinned' messages. Finding 3: When the tail comes back empty but the store is non-empty, seed the append cursor from the last stored message to prevent overwrites. Finding 4: Add tests for legacy state migration (missing pinned_head_count defaults to 0) and port the issue strands-agents#4027 end-to-end File-backed repro. Finding 5: Replace weak retry test with one that induces failure then succeeds, verifying exact transcript and removed_message_count. Style: Make _summarize_oldest atomic by reordering mutations after summary generation succeeds, removing the try/rollback pattern.
Constructor pin_first can disagree with the stored head size. Add a restore test that would pass if we still used the constructor value, plus an empty-tail append that must not restart message ids. Co-authored-by: Cursor <cursoragent@cursor.com>
test_initialize_pinned_messages_no_summary_yet was vacuous: replacing the gate (if pinned_head_count > 0:) with (if True:) left all tests green because list_messages(limit=0) returns empty in all implementations. Redesign the test to spy on list_messages and assert that no call with limit=0 is issued when pinned_head_count == 0. The gate's purpose is to suppress the pinned-head fetch for pre-compaction sessions; the spy makes that invariant enforceable — the test now fails when the gate is replaced with (if True:).
test_reduce_context_retry_after_failure_records_correct_count exercised the proactive path (e=None) for both the failure and the retry calls. The test never proved it was a retry-after-failure in the reactive sense, and no assertion on model.stream.call_count meant the failure call could be skipped without breaking the test. Fix: pass e=overflow for the first (failing) call so it exercises the reactive path which re-raises the error. Assert model.stream.call_count==2 after both calls to prove the failure call was not skipped.
Replaces the private pin-first flag with the public persisted count the reviewer asked for, and lets SlidingWindowConversationManager reuse it. Reviewer item mapping: - Blocking 2 (public pinned_head_count, carried in get_state/restore_from_session, mirrors removed_message_count): PASS. SlidingWindowConversationManager reads the persisted count instead of its own `_pin_first_applied`, which closes strands-agents#4087's shared defect for that manager too; verified by running its tests, not asserted. - Fold request (fold test_initialize_restores_summary_without_pin_first into the offset-restore test, keep the exact-transcript assert): PASS. Evidence: - pytest tests/strands/session tests/strands/agent/conversation_manager -> 306 passed in 78.93s - pytest (pin_first / sliding-window blast radius: test_conversation_manager.py, test_agent_context_manager.py, test_agent_model_state.py, test_tracking_id_history_invariant.py, _context_manager/test_context_manager.py) -> 132 passed in 53.13s - ruff check, ruff format --check, mypy on the changed source: rc=0 each - the downgrade guard is non-vacuous: coercing the persisted count to a boolean at conversation_manager.py:158 fails test_initialize_uses_persisted_pinned_head_count_not_constructor ("At index 1 diff: 'summary' != 'pinned-1'"); reverted, the same selection passes 2/2.
…count is_pinned protects a pinned message's adjacent tool-pair partner as well, so trimming keeps more leading messages than pin_first names. The sliding window recorded min(pin_first, len), so with pin_first=2 and an opening tool call the live head was 3 messages but the persisted count said 2 — a restored session reattached too few, offset past the real toolResult, and the session manager fabricated an interrupted-tool result in its place. Count the protected prefix instead, and cover it with a unit test plus two FileSessionManager restore tests (plain trim and pinned tool pair).
The restore path carried a private copy of pin_message; call the compression helper instead so the pin marker is written in one place. Also documents pinned_head_count on the base ConversationManager alongside removed_message_count.
The bare Mock agent has no aux_model, so the manager's aux_model.stream() resolved to a non-async Mock and the retry test failed with TypeError: 'async for' requires an object with __aiter__.
The pinned head must survive both the restore and the next compaction. Setting pinned_head_count to 0 on restore makes this test fail.
cefed21 to
f80fb7d
Compare
|
Rebased onto current 09-29 findings, by number:
@strandly-the-agent @JackYPCOnline — please re-review against |
|
Closing this one out to keep the queue honest, rather than leaving it open indefinitely. Timeline at close: opened 2026-08-30, last reviewer engagement 2026-09-29 — 33 days with no review, on a branch that has been rebased and kept mergeable throughout (final head Thanks @JackYPCOnline and the |
Description
Fixes
SummarizingConversationManager.pin_firstnot working correctly withFileSessionManagerandS3SessionManager.Root cause:
RepositorySessionManager.initializelists stored messages withoffset=removed_message_count. That offset was correct while nothing was pinned, but pinned head messages stay live in the transcript, so the offset skipped a live message and the summary overlapped the tail — a resumed agent lost its pinned head.removed_message_countand the pinned-head state) was advanced before the summary was generated. Summarization is the only fallible call in the reduction path, so a provider failure left the counters describing a compaction that never happened — and the next retry or restore then resumed from the wrong transcript.Changes:
ConversationManager(base): publicpinned_head_count, carried inget_state()/restore_from_session()besideremoved_message_count, so it is persisted, typed, and visible to mypy.SummarizingConversationManager: persists the pinned-head count. In_summarize_oldestthe only fallible call is_generate_summary, and everyself.*mutation now happens after it, so the operation is atomic by construction and no rollback is needed.RepositorySessionManager.initialize: uses the persistedpinned_head_countto fetch the pinned head and to offset the tail, so the restored transcript is exactly the live transcript, in order.SlidingWindowConversationManager: reuses that same persisted count instead of its own private flag, which resolves the shared defect reported in [BUG] SlidingWindowConversationManager's pin_first is lost on session restore (FileSessionManager / S3SessionManager) #4087.Related Issues
Closes #4027
Type of Change
Bug fix
Testing
SummarizingConversationManagercovering the rollback guarantee when summarization failsRepositorySessionManagercovering restore with and withoutpin_first, restore driven by the persisted count rather than the constructor argument, restore from legacy state that predates the key, and an empty tailTest results
pytest tests/strands/session tests/strands/agent/conversation_manager→ 306 passed in 78.93spytest tests/strands/agent/test_conversation_manager.py tests/strands/agent/test_agent_context_manager.py tests/strands/agent/test_agent_model_state.py tests/strands/test_tracking_id_history_invariant.py tests/strands/_context_manager/test_context_manager.py→ 132 passed in 53.13sI ran
hatch run prepare(equivalent:uv run pytest+ruff check+ruff format --check)Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.