Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@
logger = logging.getLogger("run_agent")


# Memory providers we've already warned are unavailable. Deduped because the
# gateway builds a fresh AIAgent per message, so an un-deduped warning would
# fire on every turn.
_warned_unavailable_providers: set[str] = set()


def _warn_memory_provider_unavailable(name: str) -> None:
"""Warn (once per provider) when a configured memory provider is unavailable.

``is_available()`` is a fast, side-effect-free hot-path check, so it can't
log for itself. Without this warning a provider whose credentials/config are
missing is silently dropped — the user has ``memory.provider`` set but gets
no memory and no diagnostic. A common trigger is systemd/gateway services
not inheriting ``~/.hermes/.env``. See NousResearch/hermes-agent#2765.
"""
if name in _warned_unavailable_providers:
return
_warned_unavailable_providers.add(name)
logger.warning(
"Memory provider %r is selected but reports unavailable — external memory "
"is disabled for this session (built-in memory still works). Check the "
"provider's credentials/config with 'hermes memory status'. Note: "
"systemd/gateway services do not inherit ~/.hermes/.env automatically; set "
"any required variables in the service environment.",
name,
)


def _ra():
"""Lazy reference to ``run_agent`` so callers can patch
``run_agent.OpenAI`` / ``run_agent.cleanup_vm`` / ... and have those
Expand Down Expand Up @@ -1648,6 +1676,8 @@ def init_agent(
_mp = _load_mem(_mem_provider_name)
if _mp and _mp.is_available():
agent._memory_manager.add_provider(_mp)
elif _mp is not None:
_warn_memory_provider_unavailable(_mem_provider_name)
if agent._memory_manager.providers:
_init_kwargs = {
"session_id": agent.session_id,
Expand Down Expand Up @@ -1693,6 +1723,10 @@ def init_agent(
_init_kwargs["agent_workspace"] = "hermes"
except Exception:
pass
# Deterministic memory indicators (recall/retain) emit
# through the agent's status channel — model-independent,
# same plumbing as compression/idle notices.
_init_kwargs["status_callback"] = agent._emit_status
agent._memory_manager.initialize_all(**_init_kwargs)
_ra().logger.info("Memory provider '%s' activated", _mem_provider_name)
else:
Expand Down
32 changes: 32 additions & 0 deletions agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,38 @@ def _run() -> None:
raise error_box["value"]
return result_box.get("value", "")

def describe_recall(self) -> str:
"""Build a deterministic, model-independent recall indicator line.

Call right after :meth:`prefetch_all` on the turn thread. Collects each
provider's :meth:`MemoryProvider.recall_status` and renders a single
status string (e.g. ``"👁️ Hindsight — recalled 3 memories"``) so the
user SEES memory was used regardless of whether the model mentions it.
Returns ``""`` when no provider injected memory this turn — callers can
emit the result unconditionally.
"""
segments: List[str] = []
for provider in self._providers:
try:
status = provider.recall_status()
except Exception as e:
logger.debug(
"Memory provider '%s' recall_status failed (non-fatal): %s",
provider.name, e,
)
continue
if status is None:
continue
if status.count == 1:
detail = "recalled 1 memory"
elif status.count > 1:
detail = f"recalled {status.count} memories"
else:
# count <= 0 → content injected but no discrete count (reflect).
detail = "recalled relevant memory"
segments.append(f"{status.glyph} {status.provider_label} — {detail}")
return " ".join(segments)

def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn.

Expand Down
37 changes: 37 additions & 0 deletions agent/memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,34 @@

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# Default glyph for the deterministic memory indicators. Hindsight's brand mark
# is an eye (the logo is an eye ringed by graph nodes), so the terminal-safe
# stand-in for the logo is the eye emoji. Providers can override per-status.
INDICATOR_GLYPH = "👁️"


@dataclass(frozen=True)
class RecallStatus:
"""Summary of what a provider's most recent prefetch injected this turn.

Returned by :meth:`MemoryProvider.recall_status` so the agent can emit a
deterministic, model-independent "memory was used" indicator (see
``MemoryManager.describe_recall``). ``count`` is the number of discrete
memories injected; ``0`` means content was injected but has no discrete
count (e.g. a synthesized reflect answer), which the indicator renders
generically rather than as "0 memories". ``glyph`` is the brand mark the
indicator leads with.
"""

provider_label: str
count: int
glyph: str = INDICATOR_GLYPH


class MemoryProvider(ABC):
"""Abstract base class for memory providers."""
Expand Down Expand Up @@ -113,6 +137,19 @@ def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
that do background prefetching should override this.
"""

def recall_status(self) -> Optional[RecallStatus]:
"""Describe what the most recent :meth:`prefetch` injected, for the UI.

Called by the agent right after prefetch, on the same (single) turn
thread, so it can surface a deterministic "👁️ recalled N memories"
status line that does not depend on the model choosing to mention it.

Return ``None`` (the default) when this provider injected nothing this
turn or does not want a visible indicator. Providers that override it
must reflect only the LAST prefetch — never a stale prior count.
"""
return None

def sync_turn(
self,
user_content: str,
Expand Down
11 changes: 11 additions & 0 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,17 @@ def build_turn_context(
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
# Deterministic, model-independent recall indicator: when memory was
# actually injected this turn, tell the user — don't rely on the model
# to surface it. Rendered by Hermes (via _emit_status), so it always
# shows and can't be silently dropped by the model.
if ext_prefetch_cache:
try:
_recall_indicator = agent._memory_manager.describe_recall()
if _recall_indicator:
agent._emit_status(_recall_indicator)
except Exception:
pass

# ── api_content sidecar: persist what you send ──
# The prefetch/plugin context above is injected into the API copy of this
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/memory_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,12 @@ def cmd_status(args) -> None:
if url and not is_set:
line += f" → {url}"
print(line)
print(
" Note: systemd/gateway services do not inherit ~/.hermes/.env —"
)
print(
" set any variables above in the service environment."
)
else:
print("\n Plugin: NOT installed ✗")
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
Expand Down
Loading
Loading