Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
44 changes: 44 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,40 @@
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, reason: 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.

``reason`` is the provider's ``unavailable_reason()`` — a provider-specific,
actionable hint (e.g. which package to install). Because an unavailable
provider is never initialized, this is the only place such a hint can reach
the user, so it is appended to the warning when present (#7718).
"""
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.%s",
name,
f" {reason}" if reason else "",
)


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 @@ -1708,6 +1742,12 @@ 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:
try:
_unavailable_reason = _mp.unavailable_reason()
except Exception:
_unavailable_reason = ""
_warn_memory_provider_unavailable(_mem_provider_name, _unavailable_reason)
if agent._memory_manager.providers:
_init_kwargs = {
"session_id": agent.session_id,
Expand Down Expand Up @@ -1753,6 +1793,10 @@ def init_agent(
_init_kwargs["agent_workspace"] = "hermes"
except Exception:
pass
# NOTE: status_callback (for the deterministic retain
# indicator) is wired above, CLI-only — gateway status is
# delivered on a different path (see the platform=="cli"
# block), and the indicator no-ops when it's absent.
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
48 changes: 48 additions & 0 deletions agent/memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,34 @@
import logging
import re
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


# Prompts that carry no semantic signal — trivial acknowledgements, greetings,
# slash commands, empty input. Single source of truth shared by the core
Expand Down Expand Up @@ -120,6 +144,17 @@ def initialize(self, session_id: str, **kwargs) -> None:
- user_id_alt (str): Optional alternate stable platform user identifier.
"""

def unavailable_reason(self) -> str:
"""Actionable reason this provider reports unavailable, for the caller.

``is_available()`` gates initialization, so a provider that reports
unavailable is never initialized — any diagnostic it would log from
``initialize()`` is unreachable. Return a short, user-facing hint here
(e.g. which package to install) so the caller's "provider unavailable"
warning can surface it. Empty string (the default) adds nothing.
"""
return ""

def system_prompt_block(self) -> str:
"""Return text to include in the system prompt.

Expand Down Expand Up @@ -151,6 +186,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 @@ -1172,6 +1172,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
7 changes: 5 additions & 2 deletions plugins/memory/hindsight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Long-term memory with knowledge graph, entity resolution, and multi-strategy ret
hermes memory setup # select "hindsight"
```

The setup wizard will install dependencies automatically via `uv` and walk you through configuration.
The setup wizard installs dependencies automatically via `uv`, walks you through configuration, and offers to seed the bank with a **starter memory template** (a curated set of dispositions/instructions for common agent roles) — you can skip it, and it warns before overwriting an already-configured bank.

Or manually (cloud mode with defaults):
```bash
Expand Down Expand Up @@ -77,6 +77,8 @@ Config file: `~/.hermes/hindsight/config.json`
| `recall_tags_match` | `any` | Tag matching mode: `any` / `all` / `any_strict` / `all_strict` |
| `recall_types` | `observation` | Fact types surfaced by recall (both auto-recall and the `hindsight_recall` tool). Comma-separated string or JSON list. **Default narrowed to `observation` only** (see "Behavior change" below). Set to `observation,world,experience` to also include raw facts. |
| `auto_recall` | `true` | Automatically recall memories before each turn |
| `recall_sync` | `false` | Recall synchronously against the *current* message each turn (higher relevance, adds recall latency). Default off: recall runs in the background and is injected on the next turn. |
| `recall_indicator` | `true` | Show a `👁️ Hindsight — recalled N memories` status line when auto-recall injects memory. Turn off for customer-facing agents. |

> **Behavior change — `recall_types` defaults to `observation` only.**
>
Expand All @@ -95,7 +97,8 @@ Config file: `~/.hermes/hindsight/config.json`
| `retain_every_n_turns` | `1` | Retain every N turns (1 = every turn) |
| `retain_context` | `conversation between Hermes Agent and the User` | Context label for retained memories |
| `retain_tags` | — | Default tags applied to retained memories; merged with per-call tool tags |
| `retain_source` | — | Optional `metadata.source` attached to retained memories |
| `retain_source` | — | Opt-in `metadata.source` attached to retained memories (identifies the storing client, e.g. `hermes`). Empty by default — no attribution tag ships unless you set it. |
| `retain_indicator` | `true` | Show a `👁️ Hindsight — saving to memory…` status line when a turn is saved. Turn off for customer-facing agents. |
| `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts |
| `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts |

Expand Down
Loading
Loading