Skip to content

fix: [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager (#4027) - #4066

Closed
AnnasMazhar wants to merge 10 commits into
strands-agents:mainfrom
AnnasMazhar:fix/issue-4027-pin-first-session-restore
Closed

AnnasMazhar wants to merge 10 commits into
strands-agents:mainfrom
AnnasMazhar:fix/issue-4027-pin-first-session-restore

Conversation

@AnnasMazhar

@AnnasMazhar AnnasMazhar commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes SummarizingConversationManager.pin_first not working correctly with FileSessionManager and S3SessionManager.

Root cause:

  1. RepositorySessionManager.initialize lists stored messages with offset=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.
  2. Data loss on failed summarization. The bookkeeping that records a compaction (removed_message_count and 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:

  1. ConversationManager (base): public pinned_head_count, carried in get_state() / restore_from_session() beside removed_message_count, so it is persisted, typed, and visible to mypy.
  2. SummarizingConversationManager: persists the pinned-head count. In _summarize_oldest the only fallible call is _generate_summary, and every self.* mutation now happens after it, so the operation is atomic by construction and no rollback is needed.
  3. RepositorySessionManager.initialize: uses the persisted pinned_head_count to fetch the pinned head and to offset the tail, so the restored transcript is exactly the live transcript, in order.
  4. 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

  • Added 2 regression tests for SummarizingConversationManager covering the rollback guarantee when summarization fails
  • Added regression tests for RepositorySessionManager covering restore with and without pin_first, restore driven by the persisted count rather than the constructor argument, restore from legacy state that predates the key, and an empty tail

Test results

  • pytest tests/strands/session tests/strands/agent/conversation_manager → 306 passed in 78.93s

  • pytest 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.13s

  • I ran hatch run prepare (equivalent: uv run pytest + ruff check + ruff format --check)

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR
  • My change is focused and reasonably small
  • I have added necessary tests that prove my fix is effective
  • My changes generate no new warnings

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@AnnasMazhar
AnnasMazhar requested a review from a team as a code owner August 30, 2026 13:17
@github-actions github-actions Bot added area-context Session or context related area-persistence Session management or checkpointing python Pull requests that update python code bug Something isn't working labels Aug 30, 2026
@JackYPCOnline

Copy link
Copy Markdown
Contributor

@strandly-the-agent then give this PR an initial review, catch bugs and make sure code style consistent with exisiting codebase.

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), or pin_first changed 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_first 2→3 resurrects a summarized message and drops a live one; 2→1 loses a pinned message; →None restores the old bug. Silent in all cases.
  • 🔴 Tool-pair straddle: live RES(tu1, success, "REAL RESULT") → restored RES(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_snapshot is 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 count unreachable 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, the unpin half 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):

  1. Is the persisted key meant to be a boolean? Once true is 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.
  2. Should the conversation manager own its restore contract instead of the session manager reading its private attribute? A public pinned_head_count on the base class, carried in get_state/restore_from_session, mirrors removed_message_count, is visible to mypy, and closes SlidingWindowConversationManager (#4087) for free — both subclasses already chain super(). Extending the prepend contract doesn't work (the manager has no repository handle), so the real choice is "private getattr" 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)

  1. 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).
  2. summarizing_conversation_manager.py:96-108 — the persisted schema; the one thing this PR can't revise later.
  3. summarizing_conversation_manager.py:182-222 — the rollback; correct as written, the question is only whether reordering removes the need for it.
  4. Tests last, read against step 1: test_repository_session_manager.py:1224-1307, then test_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) vs AGENTS.md:57.
  • ⚪ _async_gen_raising duplicates _mock_model_stream_error (same file, used 4×); also if False: yield None where 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_first near-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_messages calls: S3 re-runs a full paginate per call, and AgentCoreMemorySessionManager.list_messages returns [] on any exception instead of raising — a transient failure of the pinned-head call would silently drop the head. limit also 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): SlidingWindowConversationManager has the same restore defect → #4087; pins outside the leading head never survive restore → #4088.

@JackYPCOnline JackYPCOnline 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.

see initial agent's review feedback.

AnnasMazhar added a commit to AnnasMazhar/harness-sdk that referenced this pull request Sep 1, 2026
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.
@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Addressed in 5eebc3f + b84c6df:

  • Restore uses persisted pinned_head_count, not constructor pin_first. New test restores with pin_first=5 against a stored head of 2 — fails if the constructor value is used.
  • Pin markers are re-applied on the restored head so the next compaction keeps them.
  • Empty tail seeds the append cursor from the last stored message; new test asserts ids stay [0, 1, 2] instead of restarting at 0.
  • Retry-after-failure now fails first, then succeeds, and checks transcript + removed_message_count.

@JackYPCOnline

Copy link
Copy Markdown
Contributor

@strandly-the-agent please re-review it.

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for pin_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 1 still 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 --check on all 5 changed files: clean.
  • ✅ Config drift probe: restore with pin_first=3, 1, None after compacting at 2 → all match the live transcript (previously all three silently corrupted).
  • ✅ Tool-pair straddle probe: first turn uses a tool, pin_first=2 → compaction records pinned_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=True markers, 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_count key): restores without exception, count 0 → documented pre-PR behaviour; and the boolean pin_first_applied from 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 CancelledError question 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 of pin_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_count isn't documented in the base class's Attributes: docstring (conversation_manager.py:77-80 documents removed_message_count right 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.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Synced the branch with main and resolved the merge conflict — head is now 1ae3470, and the PR is MERGEABLE again.

Re-verified against the merged result rather than the pre-merge head:

  • tests/strands/session/test_repository_session_manager.py + tests/strands/agent/test_summarizing_conversation_manager.py → 102 passed
  • ruff format + lint clean

The fix still holds after main's LocalAgent refactor of repository_session_manager.py (#4257) — the pinned head is still restored from the persisted pinned_head_count, not the constructor argument.

@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?

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Re-checking the blocker here, because the review state and the code have diverged.

The CHANGES_REQUESTED is stranded on a stale commit. From the API:

review state commit
strandly-the-agent, 08-31 CHANGES_REQUESTED 565a9de58
JackYPCOnline, 08-31 CHANGES_REQUESTED 565a9de58
strandly-the-agent, 09-03 APPROVED 88dada1d3

The branch head is now 1ae3470ab, four commits past 565a9de58 (5eebc3f9d, b84c6dff5, plus two sync merges). So the bot already re-reviewed the fix and approved it; the standing request is the one left on the pre-fix commit, and GitHub therefore reports CHANGES_REQUESTED for a commit that is no longer the head.

Re-verified on the current head rather than the old one: test_repository_session_manager.py + test_summarizing_conversation_manager.py → 102 passed, ruff format + lint clean.

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 pin_first fix still in scope, or does the Context Manager path supersede it? If it's superseded I'll re-target the change at Context Manager instead of asking for another re-review here — I just don't want to keep pushing a fix for a manager that's on its way out.

@JackYPCOnline

Copy link
Copy Markdown
Contributor

@strandly-the-agent re-review the correctness and ensure no regression.

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = 0 default + assignment inside main's isinstance(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/strands for every list_messages( / removed_message_count / pinned_head_count reader: the guarded block is the only restore path with offset arithmetic; main added no parallel path; SnapshotSessionManager still 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, None all match the live transcript.
  • legacy pre-PR state dict (no pinned_head_count key): 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 on origin/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.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Synced with main — the merge conflict is resolved. Head is now a7ede8996 and GitHub reports MERGEABLE (was CONFLICTING / DIRTY).

The conflict was a single hunk in strands-py/src/strands/session/repository_session_manager.py, where main added the redact-alias tracking line at exactly the point this branch adds its append-cursor comment. Both sides are kept — nothing from either side is dropped:

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 (main @ 15da9dca2, 117 commits on from the previous base):

  • pytest tests/strands/session tests/strands/agent/conversation_manager → 231 passed, 0 failed
  • ruff format --check and ruff check on the touched files → clean
  • Merge is non-force and additive: 1ae3470ab -> a7ede8996, no history rewrite, no conflict resolution beyond the hunk above
  • Diff against main is still just the 5 files this PR has always touched (587 insertions, 19 deletions)

Review state note: the standing CHANGES_REQUESTED (JackYPCOnline, 565a9de58) is still the pre-fix commit, and the bot review approved 1ae3470ab before this sync. The only new commit is the main merge, so the diff under review is unchanged apart from the one-line resolution above.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

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 (conversation_manager.py, summarizing_conversation_manager.py, repository_session_manager.py), keep the reworked tests, run the same two test files:

# fix present
105 passed in 63.27s

# non-test files of 5eebc3f9d reverted, reworked tests kept
23 failed, 82 passed in 64.50s

So the assertion list_messages(limit=0) was called when pinned_head_count == 0 has teeth, and the restore-gate test fails on the pre-fix source instead of passing vacuously.

🔴 persist the count rather than the boolean / re-apply pin protection after restore — that is a7ede89 (fix(session): persist pinned_head_count, re-apply markers), already on the branch.

Not touched by this push, so it is clear what you are reviewing: append_message in repository_session_manager.py:101-106 still derives next_index from the in-memory latest message and falls back to 0 when it is empty — the id-0 restart path you flagged is unchanged here. Happy to take that as a follow-up if you want it in this PR rather than a separate one.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Rework pushed in 0b1b92e2. Each item mapped to what changed and what proves it.

Blocking 1 — persist a count, not a boolean: PASS

The persisted state is now the count (pinned_head_count), carried in get_state() / restore_from_session() on the base class, replacing the boolean flag. Proof it cannot silently degrade back to a boolean: coercing it at conversation_manager.py:158 (1 if state.get("pinned_head_count") else 0) fails test_initialize_uses_persisted_pinned_head_count_not_constructor with At index 1 diff: 'summary' != 'pinned-1' — the pinned head is lost, exactly the failure mode you described. Reverted, the same selection passes 2/2.

Blocking 2 — public contract instead of a private attribute read: PASS

pinned_head_count lives on the base class beside removed_message_count and is typed (mypy rc=0 on the changed source). SlidingWindowConversationManager now reads the persisted count instead of its own _pin_first_applied, resolving #4087's shared defect for that manager — verified by running its tests rather than asserting it: tests/strands/agent/test_conversation_manager.py, including test_pin_first_protects_first_n_messages_from_trimming, passes.

Non-blocking 3 — atomic by construction: PASS

Reordering did remove the rollback entirely. In _summarize_oldest the only fallible call is _generate_summary (line 193) and every self.* mutation happens after it (lines 197–205), so the snapshot variables and the bookkeeping try/except are gone. The try/except that remains in reduce_context (lines 139–141) is the caller's documented error policy — rethrow on reactive overflow, swallow on proactive — not rollback.

Style items: PASS

This includes folding test_initialize_restores_summary_without_pin_first into the offset-restore test with the exact-transcript assert kept, deleting _async_gen_raising in favour of the file's existing _mock_model_stream_error, and removing the change-narrating comments in src.

Evidence

  • pytest tests/strands/session tests/strands/agent/conversation_manager → 306 passed in 78.93s
  • pin_first / sliding-window blast radius (5 modules, including test_conversation_manager.py) → 132 passed in 53.13s
  • ruff check, ruff format --check, mypy on the changed source → rc=0

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.

@JackYPCOnline

Copy link
Copy Markdown
Contributor

@strandly-the-agent ignore previous conversation, re-review this PR

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on origin/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 --check clean (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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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):

Suggested change
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 JackYPCOnline 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.

Need more refinement

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Heads-up on the timeline, because here the review state and the code have diverged.

strandly-the-agent's last substantive review — the CHANGES_REQUESTED that is still the PR's decision — was submitted 2026-09-29 against 0b1b92e2. Everything it raised was fixed and pushed the next day in df2f9cb06 and 7947da9d6 (2026-09-30, 13:10 UTC). The bot has not re-reviewed since, so the standing change request is against a commit that is no longer the head of this branch.

Your review on 2026-10-01 landed on the post-fix head (7947da9d6) but does not name an item, so I cannot tell whether it restates the four below or asks for something new. Rather than guess, here are the four answered at head, with the runs on the head commit — and if there is a fifth, naming the file or line is enough. I should have posted a note narrating the 09-30 push; that is on me, and it is why this still reads as unresolved.

🔴 Blocker — pinned_head_count = min(pin_first, len) does not count the tool-pair partner. Fixed in df2f9cb06. The count is now the protected prefix rather than a minimum:

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)

count_protected_prefix (compression/pin_message.py) walks is_pinned from index 0 — the same predicate trimming uses, so the count cannot drift from what is actually protected.

🟡 No test covered sliding-window restore with pin_first. Added in df2f9cb06, at the level the finding was made:

  • tests/strands/session/test_repository_session_manager.py::test_file_session_sliding_window_restore_matches_in_memory — plain case over a real FileSessionManager; asserts the trim actually happened (removed_message_count > 0) and pinned_head_count == 2.
  • ...::test_file_session_sliding_window_restore_keeps_pinned_tool_pair — the straddle case: first turn calls a tool, pin_first=2; asserts pinned_head_count == 3, "REAL RESULT" present in the restored transcript, and "Tool was interrupted." absent.
  • tests/strands/agent/test_conversation_manager.py::test_pin_first_head_count_includes_protected_tool_pair_partner — the unit-level count (window_size=2, pin_first=2) asserting pinned_head_count == 3.

Not vacuous: run against the pre-fix source (0b1b92e22 src with the head tests), 2 of the 3 fail with assert 2 == 3 — the straddle defect exactly. (The plain-restore test was already green at 0b1b92e2; the straddle case is what this push adds.)

⚪ _pin_message_inline duplicated pin_message. Removed in 7947da9d6; repository_session_manager.py imports and calls pin_message. git grep _pin_message_inline at head → 0 hits under strands-py/.

⚪ pinned_head_count missing from the base Attributes: docstring. Added in the same commit, including that it covers the tool-pair partner.

Verification on head 7947da9d6, the five scoped paths from your 09-29 ledger (test_pin_message.py included):

435 passed in 126.66s (0:02:06)

That is your 432 plus the three new tests. Lint on the same tree:

ruff check .            → All checks passed!
ruff format --check .   → 728 files already formatted

Not verified by me: mypy — my environment's 2.3.1 crashes internally, same as yours.

@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.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Status check at the current head, because the newest review state is pinned to an older sha.

  • Current head: b281a8862 (updated 2026-10-08)
  • Newest CHANGES_REQUESTED: 7947da9d6 (2026-10-01) - that sha predates the current head
  • Change since that review: one merge of main into the branch (b281a8862). No further code changes of ours.
  • GitHub: mergeable, state blocked by the standing CHANGES_REQUESTED.
  • Required checks on the head: access, "2 Approvers for Bots", check-api-review-label, editorial catalog fields all pass. Trigger Strands Review is waiting.

@JackYPCOnline - please re-review against b281a8862. The code the bot flagged at 7947da9d6 is unchanged since 2026-09-30, so the earlier review still applies if it stands.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Status at head 9ba123b7c — main was merged into the branch on 2026-10-13 and GitHub reports the PR MERGEABLE.

  • The substantive blocker from the 2026-09-29 review is fixed on this head: sliding_window_conversation_manager.py:202 now sets pinned_head_count = count_protected_prefix(messages) instead of min(pin_first, len).
  • Covered by test_pin_first_head_count_includes_protected_tool_pair_partner (test_conversation_manager.py:1106) and test_file_session_sliding_window_restore_keeps_pinned_tool_pair (test_repository_session_manager.py:1672), the exact straddle case that fabricated a tool result.
  • The newest CHANGES_REQUESTED is @JackYPCOnline at 7947da9d6 (2026-10-01) with the body "Need more refinement". It names no file, line, or failing case, so I cannot action it.

Ask: please name the specific gap to refine, or re-run @strandly-the-agent against 9ba123b7c. I will push a concrete fix immediately for any item with a file and line.

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Both blocking questions are now answered on the branch.

Q1: the persisted key is a count, not a boolean.
pinned_head_count is an int on the base ConversationManager. get_state() writes it and restore_from_session() reads it. The pin_first_applied boolean is gone. Persisting the count costs the same today, and a boolean cannot be mapped to a count later without guessing.

Q2: the manager owns its restore contract.
pinned_head_count is a public attribute on the base class, next to removed_message_count. The session manager reads agent.conversation_manager.pinned_head_count instead of reading a private flag with getattr. SlidingWindowConversationManager picks up the same protection.

The findings, one by one:

  • Restore reattaches the wrong number of messages. count_protected_prefix() counts every protected head message at pin time (df2f9cb06). A tool-first turn and a changed pin_first no longer drift. The tool-pair case is covered by test_file_session_sliding_window_restore_keeps_pinned_tool_pair.
  • The head loses pin protection after a restore. New test test_file_session_second_compaction_after_restore_keeps_pinned_head runs a second compaction after the restore and asserts the pinned head is unchanged (dcdde808d). I checked that it is not vacuous: forcing pinned_head_count = 0 on restore makes it fail, with question number 1 replaced by SUMMARY.
  • The restore gate was untested. Both gating tests now exercise their paths (f63a330e6, 06ee72c8a). The retry test drives the reactive path through aux_model (96be6d769).

Verification: 332 passed on Python 3.12 across test_summarizing_conversation_manager.py, test_pin_message.py and tests/strands/session/. ruff check and ruff format --check are clean on the changed files. mypy and complexipy were not run locally. CI integration jobs are still pending.

Could you take another look?

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

Status at head cefed21bd, and a note on the review state.

  • Head moved dcdde808d → cefed21bd: main merged in, resolving the two conflicts in summarizing_conversation_manager.py and test_summarizing_conversation_manager.py. GitHub reported the PR CONFLICTING before this push.
  • The merge keeps this branch's ordering (summary generated first, bookkeeping committed only on success) and carries main's invocation_context passthrough into _generate_summary, so the two changes co-exist rather than one reverting the other.
  • The two test commits since my last update (96be6d769, dcdde808d) cover the retry path through aux_model and the compaction that runs after a restore.
  • Local: tests/strands/agent/test_summarizing_conversation_manager.py and tests/strands/session/test_repository_session_manager.py → 107 passed. ruff check and ruff format --check clean on both touched files.

On the review: the newest CHANGES_REQUESTED (7947da9d6, 2026-10-01) says "Need more refinement" with no inline comments, so I cannot tell which part of the change still misses the bar — the earlier round's points (pinned_head_count persisted as a count, atomic bookkeeping, restore-after-summary) are all addressed on this head.

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?

AnnasMazhar and others added 10 commits October 29, 2026 22:23
…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.
@AnnasMazhar
AnnasMazhar force-pushed the fix/issue-4027-pin-first-session-restore branch from cefed21 to f80fb7d Compare October 29, 2026 22:35
@AnnasMazhar

AnnasMazhar commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (f80fb7d6). Conflict resolution kept main's #5603 truncation fallback and the pinned-head restore inside the isinstance(agent, Agent) guard.

09-29 findings, by number:

  1. 🔴 pinned_head_count = min(pin_first, len) under-counts a pinned tool-pair — fixed in 6be6cf55. The sliding window now records count_protected_prefix(messages) (the same is_pinned walk trimming uses).
  2. 🟡 No sliding-window restore coverage for pin_first — fixed in 6be6cf55. test_file_session_sliding_window_restore_matches_in_memory (plain) and test_file_session_sliding_window_restore_keeps_pinned_tool_pair (straddle: pinned_head_count == 3, "REAL RESULT" present, "Tool was interrupted." absent). The straddle tests fail with assert 2 == 3 if the count is min(pin_first, len).
  3. ⚪ _pin_message_inline duplicated pin_message — fixed in 7f7fdd01. Restore calls pin_message; no _pin_message_inline left.
  4. ⚪ pinned_head_count missing from the base Attributes: docstring — fixed in 7f7fdd01, including the tool-pair partner.
  5. @JackYPCOnline "Need more refinement" named no file/line, so I treated it as 1–4 rather than new scope. If something else still misses the bar, naming the file is enough.

@strandly-the-agent @JackYPCOnline — please re-review against f80fb7d6. (The review-request API 404s for this fork contributor, so this ping is the request.)

@AnnasMazhar

Copy link
Copy Markdown
Contributor Author

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 f80fb7d6). The substantive review that blocked it was pinned to a much older sha, and every item in it was answered on later heads with tests. There is no outstanding work item here that review has not already been given a chance to see.

Thanks @JackYPCOnline and the strandly-the-agent runs for the earlier passes on the pin_first / FileSessionManager interaction. If the fix is still wanted, the reasoning and test cases in this thread stand on their own — reopen or re-point me at a fresh issue and I will rebuild against current main at that time instead of reviving a stale branch.

@AnnasMazhar AnnasMazhar closed this Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related area-persistence Session management or checkpointing bug Something isn't working python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager

3 participants