Files
hermes-agent/agent/memory_provider.py
Teknium 8dcb2b6ada refactor(agent/providers): shared ProviderBase/CatalogProviderBase and provider_media; compact contract docs
- provider_base.py: ProviderBase (name/display_name/get_setup_schema) and
  CatalogProviderBase (default_model/list_models/is_available) replace the
  identical default-method bodies duplicated across 7 provider ABCs
- provider_media.py: one save_b64/save_bytes/save_url/cache_dir implementation
  behind image_gen_provider and video_gen_provider
- memory_manager.py: _each_provider fan-out helper replaces per-hook
  try/except loops; _signature_params/_has_var_kwargs unify signature probes
- image_routing.py: _resolve_inference_value shared by base_url/api_key
  resolution; _dict_or_empty/_clean_str/_custom_provider_entries helpers
- MemoryProvider/ContextEngine/TTS/browser/web/terminal-env ABC docstrings
  compacted to their invariants; method names and signatures unchanged
2026-09-02 13:53:28 -07:00

223 lines
9.4 KiB
Python

"""Abstract base class for pluggable memory providers.
Memory providers give the agent persistent recall across sessions. Plugins
ship in ``plugins/memory/<name>/`` and are activated via ``memory.provider``;
MemoryManager allows only ONE external provider at a time (tool-schema bloat,
conflicting backends). Lifecycle is driven by MemoryManager: initialize ->
system_prompt_block / prefetch / sync_turn per turn -> tool dispatch ->
shutdown, plus the optional ``on_*`` hooks below.
"""
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# v1 = historical implicit contract (best-effort on_pre_compress() with the raw
# message list); v2 = opt-in fail-closed checkpoint (normalized evidence handoff
# + strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
# Default glyph for recall indicators; providers may use their own brand mark.
INDICATOR_GLYPH = "🧠"
@dataclass(frozen=True)
class RecallStatus:
"""What the last prefetch injected, for the deterministic recall indicator
(``MemoryManager.describe_recall``). ``count == 0`` means content without a
discrete count (e.g. a synthesized reflect answer) and renders generically."""
provider_label: str
count: int
glyph: str = INDICATOR_GLYPH
# Prompts with no semantic signal. Single source of truth for the core prefetch
# gate (turn_context.py, run_agent.py) and provider-side classifiers (honcho).
# Anchored and followed only by whitespace/punctuation, so "k8s"/"yolo"/"note"
# do NOT match while "hi!"/"thanks :)"/"done???" do.
TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'hi|hey|hello|yo|sup|'
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)'
r'[\s!?.:;,"' + "'" + r'~\u2018\u2019\u201c\u201d\u2014\u2013\u2026()\[\]{}<>*&^%$#@!+=`\u00a0]*$',
re.IGNORECASE,
)
def is_trivial_prompt(text: Optional[str]) -> bool:
"""True for empty input, slash commands and bare greetings/acknowledgements.
Skipping recall on these saves a blocking network round-trip and keeps
stale user-model context from derailing one-word replies.
"""
stripped = (text or "").strip()
if not stripped or stripped.startswith("/"):
return True
return bool(TRIVIAL_PROMPT_RE.match(stripped))
class MemoryProvider(ABC):
"""Abstract base class for memory providers."""
# Providers that durably checkpoint every successful on_pre_compress() opt
# in by setting PRE_COMPRESS_CHECKPOINT_API_VERSION; 1 = best-effort legacy.
pre_compress_checkpoint_api_version = 1
@property
@abstractmethod
def name(self) -> str:
"""Short identifier for this provider (e.g. 'builtin', 'honcho', 'hindsight')."""
# -- Core lifecycle (implement these) ------------------------------------
@abstractmethod
def is_available(self) -> bool:
"""Configured, credentialed and ready? Gates activation at agent init;
check config/deps only — no network calls."""
@abstractmethod
def initialize(self, session_id: str, **kwargs) -> None:
"""Initialize once at agent startup (connections, resources, threads).
kwargs always include ``hermes_home`` (use it for profile-scoped storage,
never hardcode ``~/.hermes``) and ``platform``. May include
``agent_context`` ("primary" | "subagent" | "cron" | "flush" — skip
writes for non-primary contexts, cron prompts would corrupt user
representations), ``agent_identity`` (profile name), ``agent_workspace``,
``parent_session_id``, ``user_id``, ``user_id_alt``.
"""
def unavailable_reason(self) -> str:
"""Short user-facing hint for the "provider unavailable" warning (e.g.
which package to install) — ``initialize()`` never runs when unavailable,
so this is the only place such a diagnostic can surface."""
return ""
def system_prompt_block(self) -> str:
"""STATIC system-prompt text (instructions, status); "" to skip.
Recalled context goes through prefetch(), not here."""
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Formatted recall context for the upcoming turn ("" if none).
Must be fast — do the recall in the background and return cached
results. ``session_id`` scopes concurrent sessions (gateway, cached agents).
"""
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""Queue a background recall after each turn; prefetch() consumes it next turn."""
def recall_status(self) -> Optional[RecallStatus]:
"""What the most recent :meth:`prefetch` injected, for a deterministic
"recalled N memories" indicator. ``None`` = nothing / no indicator.
Must reflect only the LAST prefetch, never a stale prior count."""
return None
def sync_turn(
self,
user_content: str,
assistant_content: str,
*,
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Persist a completed turn; should be non-blocking. ``messages`` is the
OpenAI-style list as of this turn, including tool calls/results."""
@abstractmethod
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""OpenAI function-calling schemas ({"name", "description", "parameters"});
[] for context-only providers."""
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Handle one of this provider's tools; must return a JSON string."""
raise NotImplementedError(f"Provider {self.name} does not handle tool {tool_name}")
def shutdown(self) -> None:
"""Clean shutdown — flush queues, close connections."""
# -- Optional hooks (override to opt in) ---------------------------------
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Per-turn tick (turn-counting, scope management, maintenance).
kwargs may include remaining_tokens, model, platform, tool_count."""
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""End-of-session extraction over the full history. Fires only at real
session boundaries (CLI exit, /reset, gateway expiry), never per-turn."""
def on_session_switch(
self,
new_session_id: str,
*,
parent_session_id: str = "",
reset: bool = False,
rewound: bool = False,
**kwargs,
) -> None:
"""session_id reassigned mid-process (/resume, /branch, /reset, /new,
gateway equivalents, context compression) without a provider teardown.
Update or reset any per-session state cached in ``initialize()`` so
later writes land in the right record. ``parent_session_id`` carries
lineage for /branch, compression and /resume ("" when none). ``reset``
is True only for a genuinely new conversation (/reset, /new) — flush
per-session buffers; False when the logical conversation continues
under a new id. ``rewound``: same id but the transcript was truncated,
so invalidate per-turn document state.
"""
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Extract insights from ``messages`` about to be compressed; the returned
text is fed into the compression summary prompt ("" = nothing)."""
return ""
def on_delegation(self, task: str, result: str, *,
child_session_id: str = "", **kwargs) -> None:
"""PARENT-side observation of a completed delegation (task prompt + final
result); the subagent itself has no provider session (skip_memory=True)."""
def get_config_schema(self) -> List[Dict[str, Any]]:
"""Setup fields for ``hermes memory setup`` ([] if none).
Each field: ``key``, ``description``, optional ``secret`` (goes to .env),
``required``, ``default``, ``choices``, ``type`` (text | integer |
number | boolean), ``minimum`` / ``maximum`` / ``step`` (numeric,
Dashboard rendering), ``url`` (where to get the credential), ``env_var``
(explicit secret env var; default auto-generated).
"""
return []
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
"""Write non-secret setup ``values`` (secrets go to .env) to the provider's
native config location. Plugins MUST either override this or use only
env vars (every schema field carrying ``env_var``) and keep the no-op."""
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror a built-in memory-tool write. ``action`` is add | replace |
remove, ``target`` is memory | user; ``metadata`` (when available) has
provenance such as write_origin, execution_context, session_id,
parent_session_id, platform, tool_name."""
def backup_paths(self) -> List[str]:
"""Absolute paths of provider state OUTSIDE HERMES_HOME (e.g. ``~/.honcho``)
so ``hermes backup`` can capture them under ``_external/`` and
``hermes import`` restore them; paths outside the home dir are skipped.
MUST work without ``initialize()`` or network — resolve from config/env."""
return []