Repository navigation
fix: [BUG] SummarizingConversationManager's pin_first does not work correctly with FileSessionManager and S3SessionManager (#4027) [replica of harness-sdk PR 4066] #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: base-pr-4066
Are you sure you want to change the base?
Changes from all commits
565a9de
5eebc3f
b84c6df
88dada1
1ae3470
a7ede89
f63a330
06ee72c
0b1b92e
df2f9cb
7947da9
b281a88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,7 +79,6 @@ def __init__( | |
| self.summarization_agent = summarization_agent | ||
| self.summarization_system_prompt = summarization_system_prompt | ||
| self.pin_first = max(0, pin_first) if pin_first is not None else None | ||
| self._pin_first_applied = False | ||
| self._summary_message: Message | None = None | ||
|
|
||
| @override | ||
|
|
@@ -98,7 +97,10 @@ def restore_from_session(self, state: dict[str, Any]) -> list[Message] | None: | |
|
|
||
| def get_state(self) -> dict[str, Any]: | ||
| """Returns a dictionary representation of the state for the Summarizing Conversation Manager.""" | ||
| return {"summary_message": self._summary_message, **super().get_state()} | ||
| return { | ||
| "summary_message": self._summary_message, | ||
| **super().get_state(), | ||
| } | ||
|
|
||
| def apply_management(self, agent: "Agent", **kwargs: Any) -> None: | ||
| """Apply management strategy to conversation history. | ||
|
|
@@ -147,6 +149,9 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A | |
| def _summarize_oldest(self, agent: "Agent") -> None: | ||
| """Summarize the oldest messages and replace them with a summary. | ||
|
|
||
| Bookkeeping updates (removed_message_count, pinned_head_count) are applied only after | ||
| summary generation succeeds, making the operation atomic by construction. | ||
|
|
||
| Args: | ||
| agent: The agent instance. | ||
|
|
||
|
|
@@ -173,9 +178,8 @@ def _summarize_oldest(self, agent: "Agent") -> None: | |
| raise ContextWindowOverflowException("Cannot summarize: insufficient messages for summarization") | ||
|
|
||
| # Pin first N messages permanently (only on first reduction) | ||
| if self.pin_first and not self._pin_first_applied: | ||
| if self.pin_first and self.pinned_head_count == 0: | ||
| apply_pin_first(agent.messages, self.pin_first) | ||
| self._pin_first_applied = True | ||
|
|
||
| # Partition [0, messages_to_summarize_count) into pinned (preserve) and non-pinned (summarize) | ||
| protected_to_preserve, to_summarize = partition_pinned(agent.messages, 0, messages_to_summarize_count) | ||
|
|
@@ -185,16 +189,17 @@ def _summarize_oldest(self, agent: "Agent") -> None: | |
|
|
||
| remaining_messages = agent.messages[messages_to_summarize_count:] | ||
|
|
||
| # Keep track of the number of messages that have been summarized thus far. | ||
| self.removed_message_count += len(to_summarize) | ||
| # If there is a summary message, don't count it in the removed_message_count. | ||
| if self._summary_message: | ||
| self.removed_message_count -= 1 | ||
| # Generate summary — if this fails, no bookkeeping is updated | ||
| summary_message = self._generate_summary(to_summarize, agent) | ||
| _ensure_tracking_id(summary_message) | ||
|
|
||
| # Generate summary | ||
| 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. | ||
| removed_count = len(to_summarize) | ||
| if self._summary_message: | ||
| removed_count -= 1 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important |
||
| self.removed_message_count += removed_count | ||
| self.pinned_head_count = len(protected_to_preserve) | ||
| self._summary_message = summary_message | ||
|
|
||
| # Replace summarized range with protected messages + summary + remaining | ||
| agent.messages[:] = protected_to_preserve + [self._summary_message] + remaining_messages | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import logging | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from ..agent.conversation_manager.compression.pin_message import pin_message | ||
| from ..agent.state import AgentState | ||
| from ..tools._tool_helpers import generate_missing_tool_result_content | ||
| from ..types.agent import LocalAgent | ||
|
|
@@ -263,22 +264,49 @@ def initialize(self, agent: "LocalAgent", **kwargs: Any) -> None: | |
| # Restore the conversation manager to its previous state, and get the optional prepend messages | ||
| prepend_messages = [] | ||
| offset = 0 | ||
| pinned_head_count = 0 | ||
| if isinstance(agent, Agent): | ||
| prepend_messages = ( | ||
| agent.conversation_manager.restore_from_session(session_agent.conversation_manager_state) or [] | ||
| ) | ||
| offset = agent.conversation_manager.removed_message_count | ||
| # The pinned head count is persisted in the base ConversationManager and restored above. | ||
| pinned_head_count = agent.conversation_manager.pinned_head_count | ||
|
|
||
| pinned_messages: list[Message] = [] | ||
| if pinned_head_count > 0: | ||
| pinned_session_messages = self.session_repository.list_messages( | ||
| session_id=self.session_id, | ||
| agent_id=agent.agent_id, | ||
| limit=pinned_head_count, | ||
| offset=0, | ||
| ) | ||
| pinned_messages = [session_message.to_message() for session_message in pinned_session_messages] | ||
| # Re-apply pin markers to the restored head so the next compaction respects them. | ||
| for i in range(len(pinned_messages)): | ||
| pin_message(pinned_messages, i) | ||
|
|
||
| # List the messages currently in the session, using an offset of the messages previously removed | ||
| # by the conversation manager. | ||
| # by the conversation manager. The offset must also skip past the still-live pinned head so the | ||
| # tail does not overlap with the messages reattached as ``pinned_messages`` above. | ||
| session_messages = self.session_repository.list_messages( | ||
| session_id=self.session_id, | ||
| agent_id=agent.agent_id, | ||
| offset=offset, | ||
| offset=pinned_head_count + offset, | ||
| ) | ||
| self._held_records[agent.agent_id] = list(session_messages) | ||
|
|
||
| # When the tail comes back empty but the store is not, seed the append cursor from the | ||
| # last stored message so appending never restarts at 0 on a non-empty store. | ||
| if len(session_messages) > 0: | ||
| 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, | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion |
||
| agent_id=agent.agent_id, | ||
| ) | ||
| if all_messages: | ||
| self._latest_agent_message[agent.agent_id] = all_messages[-1] | ||
|
|
||
| # Skip restoring messages when conversation is managed server-side | ||
| if isinstance(agent, Agent) and agent.model.stateful: | ||
|
|
@@ -288,10 +316,13 @@ def initialize(self, agent: "LocalAgent", **kwargs: Any) -> None: | |
| self.session_id, | ||
| ) | ||
| else: | ||
| # Restore the agents messages array including the optional prepend messages | ||
| agent.messages = prepend_messages + [ | ||
| session_message.to_message() for session_message in session_messages | ||
| ] | ||
| # Restore the agents messages array including the optional prepend messages and the | ||
| # still-live pinned head that compaction pushed back to the front. | ||
| agent.messages = ( | ||
| pinned_messages | ||
| + prepend_messages | ||
| + [session_message.to_message() for session_message in session_messages] | ||
| ) | ||
|
|
||
| # Fix broken session histories: https://github.com/strands-agents/harness-sdk/issues/859 | ||
| agent.messages = self._fix_broken_tool_use(agent.messages) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion
Issue:
removed_count -= 1subtracts for the previous summary message based solely onself._summary_messagebeing set, assuming that prior summary was always insideto_summarize. If a prior summary message is not part of the currentto_summarizepartition (for instance if it ended up protected/pinned or outside the summarized range), this subtraction over-decrementsremoved_message_countand the restore offset drifts.Suggestion: Guard the decrement on the prior summary actually being present in
to_summarizerather than merely existing, e.g. only subtract whenself._summary_message in to_summarize. This behavior predates the PR, but since the atomicity rework touches this exact block it's worth confirming.