perf: background post-response memory consolidation for faster replies

This commit is contained in:
Xubin Ren
2026-03-16 09:01:11 +00:00
parent 6d63e22e86
commit 46b19b15e1
5 changed files with 23 additions and 522 deletions
+18 -20
View File
@@ -100,7 +100,7 @@ class AgentLoop:
self._mcp_connected = False
self._mcp_connecting = False
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._pending_archives: list[asyncio.Task] = []
self._background_tasks: list[asyncio.Task] = []
self._processing_lock = asyncio.Lock()
self.memory_consolidator = MemoryConsolidator(
workspace=workspace,
@@ -257,8 +257,6 @@ class AgentLoop:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
await self._connect_mcp()
# Start background consolidation task
await self.memory_consolidator.start_background_task()
logger.info("Agent loop started")
while self._running:
@@ -334,9 +332,9 @@ class AgentLoop:
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
if self._pending_archives:
await asyncio.gather(*self._pending_archives, return_exceptions=True)
self._pending_archives.clear()
if self._background_tasks:
await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._background_tasks.clear()
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
@@ -344,11 +342,16 @@ class AgentLoop:
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
async def stop(self) -> None:
"""Stop the agent loop and background tasks."""
def _schedule_background(self, coro) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
task = asyncio.create_task(coro)
self._background_tasks.append(task)
task.add_done_callback(self._background_tasks.remove)
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
await self.memory_consolidator.stop_background_task()
logger.info("Agent loop stopped")
logger.info("Agent loop stopping")
async def _process_message(
self,
@@ -364,8 +367,7 @@ class AgentLoop:
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
self.memory_consolidator.record_activity(key)
await self.memory_consolidator.maybe_consolidate_by_tokens_async(session)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=0)
messages = self.context.build_messages(
@@ -375,6 +377,7 @@ class AgentLoop:
final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session))
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
@@ -383,7 +386,6 @@ class AgentLoop:
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
self.memory_consolidator.record_activity(key)
# Slash commands
cmd = msg.content.strip().lower()
@@ -394,11 +396,7 @@ class AgentLoop:
self.sessions.invalidate(session.key)
if snapshot:
task = asyncio.create_task(
self.memory_consolidator.archive_messages(snapshot)
)
self._pending_archives.append(task)
task.add_done_callback(self._pending_archives.remove)
self._schedule_background(self.memory_consolidator.archive_messages(snapshot))
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
@@ -413,8 +411,7 @@ class AgentLoop:
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines),
)
# Record activity and schedule background consolidation for non-slash commands
self.memory_consolidator.record_activity(key)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
@@ -446,6 +443,7 @@ class AgentLoop:
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session))
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None
+3 -80
View File
@@ -220,14 +220,9 @@ class MemoryStore:
class MemoryConsolidator:
"""Owns consolidation policy, locking, and session offset updates.
Consolidation runs asynchronously in the background when sessions are idle,
so it doesn't block user interactions.
"""
"""Owns consolidation policy, locking, and session offset updates."""
_MAX_CONSOLIDATION_ROUNDS = 5
_IDLE_CHECK_INTERVAL = 30 # seconds between idle checks
def __init__(
self,
@@ -247,57 +242,11 @@ class MemoryConsolidator:
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
self._background_task: asyncio.Task[None] | None = None
self._stop_event = asyncio.Event()
self._session_last_activity: dict[str, float] = {} # session_key -> last activity timestamp
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
def record_activity(self, session_key: str) -> None:
"""Record that a session is active (for idle detection)."""
self._session_last_activity[session_key] = asyncio.get_event_loop().time()
async def start_background_task(self) -> None:
"""Start the background task that checks for idle sessions and consolidates."""
if self._background_task is not None and not self._background_task.done():
return # Already running
self._stop_event.clear()
self._background_task = asyncio.create_task(self._idle_consolidation_loop())
async def stop_background_task(self) -> None:
"""Stop the background task."""
self._stop_event.set()
if self._background_task is not None and not self._background_task.done():
self._background_task.cancel()
try:
await self._background_task
except asyncio.CancelledError:
pass
self._background_task = None
async def _idle_consolidation_loop(self) -> None:
"""Background loop that checks for idle sessions and triggers consolidation."""
while not self._stop_event.is_set():
try:
await asyncio.sleep(self._IDLE_CHECK_INTERVAL)
if self._stop_event.is_set():
break
# Check all sessions for idleness
current_time = asyncio.get_event_loop().time()
for session in list(self.sessions.all()):
last_active = self._session_last_activity.get(session.key, 0)
if current_time - last_active > self._IDLE_CHECK_INTERVAL * 2:
# Session is idle, trigger consolidation
await self.maybe_consolidate_by_tokens_async(session)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error in background consolidation loop")
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
return await self.store.consolidate(messages, self.provider, self.model)
@@ -350,26 +299,8 @@ class MemoryConsolidator:
return True
return True
def maybe_consolidate_by_tokens(self, session: Session) -> None:
"""Schedule token-based consolidation to run asynchronously in background.
This method is synchronous and just schedules the consolidation task.
The actual consolidation runs in the background when the session is idle.
"""
if not session.messages or self.context_window_tokens <= 0:
return
# Schedule for background execution
asyncio.create_task(self._schedule_consolidation(session))
async def _schedule_consolidation(self, session: Session) -> None:
"""Internal method to run consolidation asynchronously."""
await self.maybe_consolidate_by_tokens_async(session)
async def maybe_consolidate_by_tokens_async(self, session: Session) -> None:
"""Async version: Loop and archive old messages until prompt fits within half the context window.
This is called from the background task when a session is idle.
"""
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
"""Loop: archive old messages until prompt fits within half the context window."""
if not session.messages or self.context_window_tokens <= 0:
return
@@ -424,11 +355,3 @@ class MemoryConsolidator:
estimated, source = self.estimate_session_prompt_tokens(session)
if estimated <= 0:
return
logger.debug(
"Token consolidation complete for {}: {}/{} via {}",
session.key,
estimated,
self.context_window_tokens,
source,
)