Skip to content

fix: [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager (#4027) [replica of harness-sdk PR 4066] - #37

Open
poshinchen wants to merge 12 commits into
base-pr-4066from
strandly-v3-pr-4066
Open

poshinchen wants to merge 12 commits into
base-pr-4066from
strandly-v3-pr-4066

Conversation

@poshinchen

Copy link
Copy Markdown
Owner

Replica of https://github.com/strands-agents/harness-sdk/pull/4066 (backticked: no cross-reference). Below: the v4-reason optimized review and a sanitized copy of live Strandly's actual comments.

AnnasMazhar and others added 12 commits August 30, 2026 14:15
…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>
…rst-session-restore

# Conflicts:
#	strands-py/src/strands/session/repository_session_manager.py
#	strands-py/tests/strands/session/test_repository_session_manager.py
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.

@poshinchen poshinchen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Strandly v4-reason (optimized) review — run af1746, Opus 4.8

Assessment: comment

Assessment: Comment

The fix is well-structured: making the summarization bookkeeping atomic (mutate only after _generate_summary succeeds) and persisting a pinned_head_count so restore can both reattach the live pinned head and offset the tail past it. Test coverage is strong, including legacy-state, empty-tail, pin-marker, and retry-after-failure cases.

Review Categories
  • Correctness: One edge case worth confirming — pinned_head_count is set from the pinned subset within the summarize range, which could under-count if the split point ever lands inside the pinned prefix on a later compaction. The prior-summary removed_count -= 1 also assumes the old summary is in to_summarize.
  • Performance: Empty-tail fallback lists the entire store just to read the last message id.
  • API surface: New public pinned_head_count + persisted-state key — confirm API review/label.
  • Testing: Primary restore test asserts text-only; consider verifying full shape/markers.

Solid, carefully reasoned fix with thoughtful regression tests.

# All mutations below this point: summary generation succeeded, commit the changes.
removed_count = len(to_summarize)
if self._summary_message:
removed_count -= 1

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Important
Issue: self.pinned_head_count = len(protected_to_preserve) is an unconditional assignment to the count of pinned messages found only within [0, messages_to_summarize_count). On a later compaction where the adjusted messages_to_summarize_count does not span the entire live pinned head (e.g. the split point lands inside the pinned prefix), protected_to_preserve would under-count the still-live pinned messages, and the persisted pinned_head_count would shrink below the real head — causing restore to reattach too few head messages and offset the tail incorrectly.
Suggestion: Consider deriving the count from the full protected prefix of the resulting transcript (e.g. count_protected_prefix(agent.messages) after the splice), or assert that messages_to_summarize_count always covers the pinned head. Please confirm the split-point adjustment can never land inside the pinned prefix.

self._latest_agent_message[agent.agent_id] = session_messages[-1]
elif pinned_head_count > 0 or offset > 0:
all_messages = self.session_repository.list_messages(
session_id=self.session_id,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Suggestion
Issue: The empty-tail fallback issues an unbounded list_messages(...) with no limit purely to read the last stored message's message_id. On a large store this materializes the entire message history just to recover one id, which can be expensive during restore.
Suggestion: If the repository API supports it, fetch only the final record (e.g. a reverse/last-N query or a dedicated "latest message" lookup) instead of listing everything. If no such API exists, a brief comment noting the cost tradeoff would help future readers.

self._summary_message = self._generate_summary(to_summarize, agent)
# Assign tracking id to the summary message since it bypasses the append method.
_ensure_tracking_id(self._summary_message)
# All mutations below this point: summary generation succeeded, commit the changes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Suggestion
Issue: removed_count -= 1 subtracts for the previous summary message based solely on self._summary_message being set, assuming that prior summary was always inside to_summarize. If a prior summary message is not part of the current to_summarize partition (for instance if it ended up protected/pinned or outside the summarized range), this subtraction over-decrements removed_message_count and the restore offset drifts.
Suggestion: Guard the decrement on the prior summary actually being present in to_summarize rather than merely existing, e.g. only subtract when self._summary_message in to_summarize. This behavior predates the PR, but since the atomicity rework touches this exact block it's worth confirming.

@poshinchen

Copy link
Copy Markdown
Owner Author

Live Strandly's actual comments on the original PR 4066 (sanitized copy: mentions escaped, links backticked)

Review (CHANGES_REQUESTED):

Request changes. The bug is genuinely fixed for the case strands-agents#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 [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager strands-agents/harness-sdk#4027's own e2e repro (FileSessionManager + stub model, incl. a two-compactions-then-resume variant): 3 passed on head; the same file fails on main3f1e99b``.
  • ✅ 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 af

Review (APPROVED):

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 [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager strands-agents/harness-sdk#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,

Review (APPROVED):

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 p

Review (CHANGES_REQUESTED):

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 strands-agents#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: strands-agents#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 [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager strands-agents/harness-sdk#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

Inline at strands-py/src/strands/session/repository_session_manager.py:238:

🔴 Restore recomputes the pinned-head size from the constructor argument, so it reattaches the wrong messages.

The persisted state records whether pinning happened; the size comes from whatever pin_first is today. Those two disagree in two ordinary cases, both silent:

  • pin_first changed between runs — verified: raising it resurrects an already-summarized message and drops a live one; lowering it loses a pinned message; None falls back to the old bug.
  • the pinned head ends mid tool-pair — with pin_first=2 and a first turn that called a tool, compaction preserves 3 messages while restore reattaches 2. The real tool result is gone and a fabricated "Tool was interrupted." result is substituted, so the model resumes believing its own tool call failed. Any tool-using agent with pin_first=2 on a File/S3 session hits this on the first resume after a compaction.

Suggestion: persist the count the completed compaction actually preserved instead of the boolean, and stop consulting pin_first here. Verified: the issue's repro still passes and all three drift cases become correct. Costs: same migration price as the boolean (additive key, missing → 0 → pre-PR behaviour) — but a stored true can never be mapped back to a count later, so the boolean is the expensive order. A public pinned_head_count on ConversationManager beside removed_message_count would also remove the cross-module private read (the repo's first bookkeeping-private getattr — invisible to mypy) and fix SlidingWindowConversationManager (strands-agents#4087) for free, at the cost of one public attribute and a third file.

Inline at strands-py/src/strands/agent/conversation_manager/summarizing_conversation_manager.py:99:

🔴 Persisting pin_first_applied=True stops the restored head from ever being re-pinned — the next compaction summarizes the pinned messages away.

The pinned marker lives on the live message dict and never reaches the store (messages are appended before pinning; update_message is redaction-only — probed directly, no stored message carries the metadata). After a restore the reattached head has no markers, and because the flag now round-trips, apply_pin_first is skipped and the partition sees nothing pinned.

Verified end-to-end (Agent + FileSessionManager + pin_first=2: compact → restore → converse to the next compaction): both pinned messages are summarized away. Base doesn't do this, because it re-applies pinning. Net effect as written: the first restore is fixed and the loss moves to the next compaction after it — harder to notice, since the resumed transcript looks correct.

Suggestion: re-apply the markers to the reattached head during restore, using the persisted count from the other comment to identify exactly which messages were live-and-pinned. Cost: one call on the restore path.

Inline at strands-py/src/strands/session/repository_session_manager.py:256:

🟡 If the raised offset lands past the last stored message, the next append overwrites stored message 0.

An empty tail leaves the append cursor unset, and numbering then restarts at 0 — verified against a real File store: the store became ['NEW','m1','m2'] where base would have appended at id 3. The first stored message is destroyed, not shadowed.

Today the trigger is pin_first raised on an existing session (see the other comment); persisting the count removes that arithmetic. What remains is that nothing notices an offset outrunning the store, so the same silent destruction waits behind any future id gap or a repository whose list_messages returns [] on a transient failure instead of raising — AgentCore's does.

Suggestion: when the tail comes back empty but the store is not, seed the append cursor from the last stored message rather than leaving it unset — appending must never restart at 0 on a non-empty store. Costs: one branch, plus at most one extra list call on that path only.

Inline at strands-py/tests/strands/session/test_repository_session_manager.py:1366:

🟡 The new restore gate isn't actually tested — replacing it with if True: keeps all 172 tests green.

  • This test is arithmetically vacuous: with no summary the offset is pin_first + 0, so reattaching the head and skipping past it cancel out. It passes with or without the fix.
  • Nothing feeds a pre-PR state dict (no pin_first_applied key), so writing that read as a bare index also survives the suite — the one mutation that would KeyError on every existing session.
  • All six new tests hand-seed the state they assert on, which is exactly how the post-restore-compaction loss escaped.

Suggestion: (1) one test that restores a legacy state dict with the key missing — it kills both mutations at once and locks in the documented upgrade behaviour; (2) port the issue's end-to-end File-backed repro (compact → fresh process → resume) into the suite: it runs in 0.64s and fails on base. Costs: two tests, no new fixtures.

Inline at strands-py/tests/strands/agent/test_summarizing_conversation_manager.py:986:

🟡 This test never induces a previous failure — it's the plain success path under a retry name.

Fresh manager, fresh agent, a model that succeeds: nothing about retry-after-failure is exercised, and it caught none of the 13 mutations tried against the diff. Two smaller things ride along: removed_message_count >= 1 and "SUMMARY" in str(...) are loose enough to pass on broken bookkeeping, and the comment calling the summary an assistant message is wrong — summary generation forces role user.

Suggestion: make the model fail once and then succeed, asserting the exact transcript and removed_message_count == 1. Verified: that version fails on base (counter 2, transcript identical) and passes here — it is precisely the corruption this PR fixes. Seeding a prior _summary_message into the failing test would additionally cover the _summary_message rollback line, which currently survives deletion untested. Cost: one test rewritten.

Inline at strands-py/src/strands/agent/conversation_manager/sliding_window_conversation_manager.py:200:

🔴 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 = 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 strands-agents#4087.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants