Skip to content

Commit c579d67

Browse files
committed
fix(memory): preserve consolidation turn boundaries under chunk cap
Made-with: Cursor
1 parent bfe53eb commit c579d67

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

nanobot/agent/memory.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,22 @@ def pick_consolidation_boundary(
400400

401401
return last_boundary
402402

403+
def _cap_consolidation_boundary(
404+
self,
405+
session: Session,
406+
end_idx: int,
407+
) -> int | None:
408+
"""Clamp the chunk size without breaking the user-turn boundary."""
409+
start = session.last_consolidated
410+
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
411+
return end_idx
412+
413+
capped_end = start + self._MAX_CHUNK_MESSAGES
414+
for idx in range(capped_end, start, -1):
415+
if session.messages[idx].get("role") == "user":
416+
return idx
417+
return None
418+
403419
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
404420
"""Estimate current prompt size for the normal session history view."""
405421
history = session.get_history(max_messages=0)
@@ -495,14 +511,19 @@ async def maybe_consolidate_by_tokens(self, session: Session) -> None:
495511
return
496512

497513
end_idx = boundary[0]
514+
end_idx = self._cap_consolidation_boundary(session, end_idx)
515+
if end_idx is None:
516+
logger.debug(
517+
"Token consolidation: no capped boundary for {} (round {})",
518+
session.key,
519+
round_num,
520+
)
521+
return
522+
498523
chunk = session.messages[session.last_consolidated:end_idx]
499524
if not chunk:
500525
return
501526

502-
if len(chunk) > self._MAX_CHUNK_MESSAGES:
503-
chunk = chunk[:self._MAX_CHUNK_MESSAGES]
504-
end_idx = session.last_consolidated + len(chunk)
505-
506527
logger.info(
507528
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
508529
round_num,

tests/agent/test_consolidator.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,52 @@ async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
7676
consolidator.archive = AsyncMock(return_value=True)
7777
await consolidator.maybe_consolidate_by_tokens(session)
7878
consolidator.archive.assert_not_called()
79+
80+
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator):
81+
"""Chunk cap should rewind to the last user boundary within the cap."""
82+
consolidator._SAFETY_BUFFER = 0
83+
session = MagicMock()
84+
session.last_consolidated = 0
85+
session.key = "test:key"
86+
session.messages = [
87+
{
88+
"role": "user" if i in {0, 50, 61} else "assistant",
89+
"content": f"m{i}",
90+
}
91+
for i in range(70)
92+
]
93+
consolidator.estimate_session_prompt_tokens = MagicMock(
94+
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
95+
)
96+
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
97+
consolidator.archive = AsyncMock(return_value=True)
98+
99+
await consolidator.maybe_consolidate_by_tokens(session)
100+
101+
archived_chunk = consolidator.archive.await_args.args[0]
102+
assert len(archived_chunk) == 50
103+
assert archived_chunk[0]["content"] == "m0"
104+
assert archived_chunk[-1]["content"] == "m49"
105+
assert session.last_consolidated == 50
106+
107+
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator):
108+
"""If the cap would cut mid-turn, consolidation should skip that round."""
109+
consolidator._SAFETY_BUFFER = 0
110+
session = MagicMock()
111+
session.last_consolidated = 0
112+
session.key = "test:key"
113+
session.messages = [
114+
{
115+
"role": "user" if i in {0, 61} else "assistant",
116+
"content": f"m{i}",
117+
}
118+
for i in range(70)
119+
]
120+
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken"))
121+
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
122+
consolidator.archive = AsyncMock(return_value=True)
123+
124+
await consolidator.maybe_consolidate_by_tokens(session)
125+
126+
consolidator.archive.assert_not_awaited()
127+
assert session.last_consolidated == 0

0 commit comments

Comments
 (0)