"""MemoryManager — orchestrates the built-in memory provider plus at most ONE external plugin memory provider. Single integration point in run_agent.py. Replaces scattered per-backend code with one manager that delegates to registered providers. The BuiltinMemoryProvider is always registered first and cannot be removed. Only ONE external (non-builtin) provider is allowed at a time — attempting to register a second external provider is rejected with a warning. This prevents tool schema bloat and conflicting memory backends. Usage in run_agent.py: self._memory_manager = MemoryManager() self._memory_manager.add_provider(BuiltinMemoryProvider(...)) # Only ONE of these: self._memory_manager.add_provider(plugin_provider) # System prompt prompt_parts.append(self._memory_manager.build_system_prompt()) # Pre-turn context = self._memory_manager.prefetch_all(user_message) # Post-turn self._memory_manager.sync_all(user_msg, assistant_response) self._memory_manager.queue_prefetch_all(user_msg) """ from __future__ import annotations import json import logging from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider logger = logging.getLogger(__name__) class MemoryManager: """Orchestrates the built-in provider plus at most one external provider. The builtin provider is always first. Only one non-builtin (external) provider is allowed. Failures in one provider never block the other. """ def __init__(self) -> None: self._providers: List[MemoryProvider] = [] self._tool_to_provider: Dict[str, MemoryProvider] = {} self._has_external: bool = False # True once a non-builtin provider is added # -- Registration -------------------------------------------------------- def add_provider(self, provider: MemoryProvider) -> None: """Register a memory provider. Built-in provider (name ``"builtin"``) is always accepted. Only **one** external (non-builtin) provider is allowed — a second attempt is rejected with a warning. """ is_builtin = provider.name == "builtin" if not is_builtin: if self._has_external: existing = next( (p.name for p in self._providers if p.name != "builtin"), "unknown" ) logger.warning( "Rejected memory provider '%s' — external provider '%s' is " "already registered. Only one external memory provider is " "allowed at a time. Configure which one via memory.provider " "in config.yaml.", provider.name, existing, ) return self._has_external = True self._providers.append(provider) # Index tool names → provider for routing for schema in provider.get_tool_schemas(): tool_name = schema.get("name", "") if tool_name and tool_name not in self._tool_to_provider: self._tool_to_provider[tool_name] = provider elif tool_name in self._tool_to_provider: logger.warning( "Memory tool name conflict: '%s' already registered by %s, " "ignoring from %s", tool_name, self._tool_to_provider[tool_name].name, provider.name, ) logger.info( "Memory provider '%s' registered (%d tools)", provider.name, len(provider.get_tool_schemas()), ) @property def providers(self) -> List[MemoryProvider]: """All registered providers in order.""" return list(self._providers) @property def provider_names(self) -> List[str]: """Names of all registered providers.""" return [p.name for p in self._providers] def get_provider(self, name: str) -> Optional[MemoryProvider]: """Get a provider by name, or None if not registered.""" for p in self._providers: if p.name == name: return p return None # -- System prompt ------------------------------------------------------- def build_system_prompt(self) -> str: """Collect system prompt blocks from all providers. Returns combined text, or empty string if no providers contribute. Each non-empty block is labeled with the provider name. """ blocks = [] for provider in self._providers: try: block = provider.system_prompt_block() if block and block.strip(): blocks.append(block) except Exception as e: logger.warning( "Memory provider '%s' system_prompt_block() failed: %s", provider.name, e, ) return "\n\n".join(blocks) # -- Prefetch / recall --------------------------------------------------- def prefetch_all(self, query: str, *, session_id: str = "") -> str: """Collect prefetch context from all providers. Returns merged context text labeled by provider. Empty providers are skipped. Failures in one provider don't block others. """ parts = [] for provider in self._providers: try: result = provider.prefetch(query, session_id=session_id) if result and result.strip(): parts.append(result) except Exception as e: logger.debug( "Memory provider '%s' prefetch failed (non-fatal): %s", provider.name, e, ) return "\n\n".join(parts) def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None: """Queue background prefetch on all providers for the next turn.""" for provider in self._providers: try: provider.queue_prefetch(query, session_id=session_id) except Exception as e: logger.debug( "Memory provider '%s' queue_prefetch failed (non-fatal): %s", provider.name, e, ) # -- Sync ---------------------------------------------------------------- def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: """Sync a completed turn to all providers.""" for provider in self._providers: try: provider.sync_turn(user_content, assistant_content, session_id=session_id) except Exception as e: logger.warning( "Memory provider '%s' sync_turn failed: %s", provider.name, e, ) # -- Tools --------------------------------------------------------------- def get_all_tool_schemas(self) -> List[Dict[str, Any]]: """Collect tool schemas from all providers.""" schemas = [] seen = set() for provider in self._providers: try: for schema in provider.get_tool_schemas(): name = schema.get("name", "") if name and name not in seen: schemas.append(schema) seen.add(name) except Exception as e: logger.warning( "Memory provider '%s' get_tool_schemas() failed: %s", provider.name, e, ) return schemas def get_all_tool_names(self) -> set: """Return set of all tool names across all providers.""" return set(self._tool_to_provider.keys()) def has_tool(self, tool_name: str) -> bool: """Check if any provider handles this tool.""" return tool_name in self._tool_to_provider def handle_tool_call( self, tool_name: str, args: Dict[str, Any], **kwargs ) -> str: """Route a tool call to the correct provider. Returns JSON string result. Raises ValueError if no provider handles the tool. """ provider = self._tool_to_provider.get(tool_name) if provider is None: return json.dumps({"error": f"No memory provider handles tool '{tool_name}'"}) try: return provider.handle_tool_call(tool_name, args, **kwargs) except Exception as e: logger.error( "Memory provider '%s' handle_tool_call(%s) failed: %s", provider.name, tool_name, e, ) return json.dumps({"error": f"Memory tool '{tool_name}' failed: {e}"}) # -- Lifecycle hooks ----------------------------------------------------- def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: """Notify all providers of a new turn. kwargs may include: remaining_tokens, model, platform, tool_count. """ for provider in self._providers: try: provider.on_turn_start(turn_number, message, **kwargs) except Exception as e: logger.debug( "Memory provider '%s' on_turn_start failed: %s", provider.name, e, ) def on_session_end(self, messages: List[Dict[str, Any]]) -> None: """Notify all providers of session end.""" for provider in self._providers: try: provider.on_session_end(messages) except Exception as e: logger.debug( "Memory provider '%s' on_session_end failed: %s", provider.name, e, ) def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Notify all providers before context compression. Returns combined text from providers to include in the compression summary prompt. Empty string if no provider contributes. """ parts = [] for provider in self._providers: try: result = provider.on_pre_compress(messages) if result and result.strip(): parts.append(result) except Exception as e: logger.debug( "Memory provider '%s' on_pre_compress failed: %s", provider.name, e, ) return "\n\n".join(parts) def on_memory_write(self, action: str, target: str, content: str) -> None: """Notify external providers when the built-in memory tool writes. Skips the builtin provider itself (it's the source of the write). """ for provider in self._providers: if provider.name == "builtin": continue try: provider.on_memory_write(action, target, content) except Exception as e: logger.debug( "Memory provider '%s' on_memory_write failed: %s", provider.name, e, ) def on_delegation(self, task: str, result: str, *, child_session_id: str = "", **kwargs) -> None: """Notify all providers that a subagent completed.""" for provider in self._providers: try: provider.on_delegation( task, result, child_session_id=child_session_id, **kwargs ) except Exception as e: logger.debug( "Memory provider '%s' on_delegation failed: %s", provider.name, e, ) def shutdown_all(self) -> None: """Shut down all providers (reverse order for clean teardown).""" for provider in reversed(self._providers): try: provider.shutdown() except Exception as e: logger.warning( "Memory provider '%s' shutdown failed: %s", provider.name, e, ) def initialize_all(self, session_id: str, **kwargs) -> None: """Initialize all providers. Automatically injects ``hermes_home`` into *kwargs* so that every provider can resolve profile-scoped storage paths without importing ``get_hermes_home()`` themselves. """ if "hermes_home" not in kwargs: from hermes_constants import get_hermes_home kwargs["hermes_home"] = str(get_hermes_home()) for provider in self._providers: try: provider.initialize(session_id=session_id, **kwargs) except Exception as e: logger.warning( "Memory provider '%s' initialize failed: %s", provider.name, e, )