Skip to content
Merged
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
20 changes: 15 additions & 5 deletions hindsight-integrations/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ While Hermes still bundles `plugins/memory/hindsight/`, **the bundled copy wins*
is bundled → `~/.hermes/plugins/` → project → entry point, first hit wins, so installing this plugin
alongside the bundled one is inert. When Hermes drops the bundled copy, `hermes update` migrates
existing users automatically via `hermes_cli/memory_provider_migration.py`, which resolves the
provider name against the Hermes plugin catalog. Submitting that entry is **our** job per the
handoff notes, which makes `plugin-catalog-entry.yaml` (to be PR'd into `NousResearch/hermes-agent`
as `plugin-catalog/hindsight.yaml`) a hard prerequisite of their removal — see the comments in that
file.
provider name against the Hermes plugin catalog.

The catalog entry lives in their repo at
[`plugin-catalog/hindsight.yaml`](https://github.com/NousResearch/hermes-agent/blob/main/plugin-catalog/hindsight.yaml)
and pins this directory at a specific commit. **Changes here do not reach users until that pin
moves**, so anything shipped from this tree needs a follow-up PR to hermes-agent bumping `sha` and
`version` together.

`local_embedded` mode needs `hindsight-all`, which `pyproject.toml` deliberately does not declare
(it would push the local-ML stack onto cloud-mode users). The setup wizard installs it, and
Expand Down Expand Up @@ -186,7 +189,14 @@ Available in `hybrid` and `tools` memory modes:

## Client Version

Requires `hindsight-client >= 0.6.1`. The plugin auto-upgrades on session start if an older version is detected.
Requires `hindsight-client >= 0.10.1` and, for `local_embedded`, `hindsight-embed >= 0.10.1`. The plugin
auto-upgrades the client on session start if an older version is detected.

The floor is 0.10.1 rather than the 0.6.1 this plugin needs at the API level because
`hindsight-embed` 0.10.0 breaks `local_embedded` outright: its daemon probe cleared the calling
thread's event loop, so the next client call failed with `Timeout context manager should be used
inside a task`. If you installed between 2026-09-14 and 2026-09-21, run `hermes update` (or
`hermes plugins update hindsight`) to move off it.

## Development

Expand Down
34 changes: 29 additions & 5 deletions hindsight-integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ def __init__(self):
setattr(self, f"_{name}", "")
self._session_id = self._parent_session_id = self._document_id = ""
self._status_callback: Optional[Callable[[str], None]] = None
# Set from initialize() kwargs; defaults keep _start_embedded_daemon safe when a
# host constructs the provider without calling initialize() (availability probes do).
self._warning_callback: Optional[Callable[[str], None]] = None
self._platform: str = "cli"

# Retain: single-writer model — sync_turn() enqueues, one writer thread
# drains sequentially (ad-hoc threads raced interpreter shutdown:
Expand Down Expand Up @@ -920,6 +924,9 @@ def initialize(self, session_id: str, **kwargs) -> None:
# Status channel for the retain indicator (recall reports via recall_status()).
if callable(kwargs.get("status_callback")):
self._status_callback = kwargs["status_callback"]
# Gated presentation for automatic startup warnings (agent._emit_warning on CLI).
self._warning_callback = kwargs.get("warning_callback") if callable(kwargs.get("warning_callback")) else None
self._platform = str(kwargs.get("platform") or "cli")
# session_id stays in tags so processes for one session remain filterable together.
self._document_id = _mint_document_id(self._session_id)
_maybe_upgrade_client()
Expand Down Expand Up @@ -1090,11 +1097,20 @@ def _start_embedded_daemon(self) -> None:
"to cloud / local_external mode via 'hermes memory setup'."
)
logger.warning(msg)
# Also print: otherwise the user would only see Hermes get sluggish.
# Surface to the terminal too — a daemon that never starts would otherwise fail silently and
# the user would only see Hermes get sluggish (issue #13125). This is an automatic
# startup diagnostic: it goes through the agent's gated warning sink when wired,
# otherwise through the shared render boundary; the log line above never does.
with contextlib.suppress(Exception):
# Surface to the terminal too — a daemon that never starts would otherwise fail silently and
# the user would only see Hermes get sluggish. (issue #13125)
print(f" ⚠ {msg}", file=sys.stderr, flush=True)
if self._warning_callback is not None:
self._warning_callback(msg)
else:
from gateway.warning_notifications import render_notification

render_notification(
lambda: print(f" ⚠ {msg}", file=sys.stderr, flush=True),
platform=self._platform,
)
self._mode = "disabled"
return
spawn_context_thread(self._daemon_start_worker, name="hindsight-daemon-start").start()
Expand Down Expand Up @@ -1415,7 +1431,15 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
# Advance the watermark only after the delta is queued so a later retain
# doesn't re-ship turns already handed to the writer.
if update_mode == "append":
self._last_retained_turn_count = len(self._session_turns)
# Every buffered turn has now been shipped (the retain content was
# snapshotted into the closure above). Append retains only ever read
# the un-retained tail — sync_turn slices from the watermark and
# flush-on-switch flushes what's left — so drop the retained turns
# instead of letting the buffer grow for the whole session. Overwrite
# mode is deliberately untouched: it resends the full session each
# retain and must keep every turn.
self._session_turns.clear()
self._last_retained_turn_count = 0

def _enqueue_retain(self, job: Callable[[], None]) -> None:
"""Hand *job* to the (lazily started) writer and arm the atexit drain."""
Expand Down
37 changes: 0 additions & 37 deletions hindsight-integrations/hermes/plugin-catalog-entry.yaml

This file was deleted.

4 changes: 1 addition & 3 deletions hindsight-integrations/hermes/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@ name: hindsight
version: 1.0.0
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
pip_dependencies:
- "hindsight-client>=0.6.1"
- "hindsight-client>=0.10.1"
requires_env: []
hooks:
- on_session_end
11 changes: 9 additions & 2 deletions hindsight-integrations/hermes/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ license = { text = "MIT" }
# every `hermes update`. Keep upper bounds: Hermes pins its own deps exactly and refuses a
# plugin whose requirements cannot resolve against them.
dependencies = [
"hindsight-client>=0.6.1,<1",
"hindsight-embed>=0.6.1,<1",
# Floor is 0.10.1, not the 0.6.1 this plugin actually needs at the API level:
# hindsight-embed 0.10.0 shipped a probe that ran `asyncio.run` on the caller's
# thread, clearing its event loop, so the next hindsight-client call hit an aiohttp
# session bound to a dead loop ("Timeout context manager should be used inside a
# task"). That breaks local_embedded outright. Anyone who installed between
# 2026-09-14 and 2026-09-21 is pinned to the broken release until a floor moves them
# off it, so keep these at >=0.10.1 rather than widening them back.
"hindsight-client>=0.10.1,<1",
"hindsight-embed>=0.10.1,<1",
]

# The plugin is loaded from a directory by Hermes, not installed as a distribution.
Expand Down
6 changes: 4 additions & 2 deletions hindsight-integrations/hermes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
_DEFAULT_LOCAL_URL = "http://localhost:8888"
# Keep in sync with plugin.yaml and pyproject.toml.
_MIN_CLIENT_VERSION = "0.6.1"
# Keep in sync with plugin.yaml and pyproject.toml. Raised to 0.10.1 with the embed
# floor (see pyproject.toml): _maybe_upgrade_client() then pulls the 0.10.0 cohort's
# client forward on session start instead of waiting for a `hermes update`.
_MIN_CLIENT_VERSION = "0.10.1"
_DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request
_DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default
# ``metadata.source`` on retained memories is OPT-IN (AGENTS.md forbids
Expand Down
75 changes: 73 additions & 2 deletions hindsight-integrations/hermes/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ def _retain_item(fake: FakeClient, index: int = 0) -> dict:
return fake.retains[index]["items"][0]


def _turns_of(fake: FakeClient, index: int = 0) -> list[list[str]]:
"""Message texts per turn in one retain. Content is ``"[" + ",".join(turns) + "]"``
where each turn is itself a JSON array, so the whole payload is a list of turns."""
return [[m["content"] for m in turn] for turn in json.loads(_retain_item(fake, index)["content"])]


def test_sync_turn_retains_the_turn(provider):
instance, fake = provider({"bank_id": "team", "retain_tags": "hermes"})
instance.sync_turn("what is my name?", "Ada.")
Expand Down Expand Up @@ -115,11 +121,76 @@ def test_session_switch_starts_a_new_document(provider):
instance.shutdown()

# The switch flushes the old session's buffer under the old document id first,
# so the new session's turn can never land in the previous document.
assert [call["document_id"] for call in fake.retains] == ["session-1", "session-1", "session-2"]
# so the new session's turn can never land in the previous document. In append
# mode the buffer is already empty here (sync_turn shipped and dropped the turn),
# so there is nothing left to flush — previously this re-shipped the retained
# turn under session-1 a second time, duplicating it in the document.
assert [call["document_id"] for call in fake.retains] == ["session-1", "session-2"]


def test_register_exposes_the_provider_to_hermes():
registered = []
plugin.register(type("Ctx", (), {"register_memory_provider": lambda _self, p: registered.append(p)})())
assert registered and registered[0].name == "hindsight"


def test_append_mode_drops_retained_turns_from_the_buffer(provider):
"""Append retains ship a delta, so keeping every turn would pin the whole session
in memory on a long-running gateway (hermes-agent #62950).

Append mode comes from the API capability probe, which the fixture pins on — it is
not a config key.
"""
instance, fake = provider({})
instance.sync_turn("one", "1")
instance.sync_turn("two", "2")

# Buffer state is read before shutdown(); retains only land once the writer drains.
assert instance._session_turns == []
assert instance._last_retained_turn_count == 0
instance.shutdown()

# Each retain still carries only its own un-retained tail, never a replay.
assert _turns_of(fake, 0) == [["User: one", "Assistant: 1"]]
assert _turns_of(fake, 1) == [["User: two", "Assistant: 2"]]


def test_overwrite_mode_keeps_every_turn(provider, monkeypatch):
"""Overwrite resends the full session on each retain, so its buffer must NOT be
cleared — only the append path drops shipped turns."""
instance, fake = provider({})
# An API without update_mode='append' support: the fixture pins the probe on, so
# turn it back off to exercise the overwrite path.
monkeypatch.setattr(plugin, "_check_api_supports_update_mode_append", lambda *a, **k: False)
instance.sync_turn("one", "1")
instance.sync_turn("two", "2")

assert len(instance._session_turns) == 2 # one buffered entry per turn
instance.shutdown()

# The second retain resends the whole session, which is what overwrite means.
assert _turns_of(fake, 1) == [["User: one", "Assistant: 1"], ["User: two", "Assistant: 2"]]


def test_root_warning_goes_through_the_hosts_warning_callback(provider, monkeypatch):
"""The 'cannot run as root' notice is an automatic startup diagnostic: hosts that
wire a gated sink must receive it there, not on stderr (hermes-agent cd3de040ab9)."""
seen = []
instance, _ = provider({}, warning_callback=seen.append, platform="telegram")
assert instance._platform == "telegram"

monkeypatch.setattr(plugin.os, "geteuid", lambda: 0, raising=False)
instance._mode = "local_embedded"
instance._start_embedded_daemon()

assert len(seen) == 1 and "cannot run as root" in seen[0]
assert instance._mode == "disabled"
instance.shutdown()


def test_warning_sink_defaults_exist_without_initialize():
"""_start_embedded_daemon reads these directly, and availability probes construct a
provider without ever calling initialize() — so __init__ must supply both."""
bare = plugin.HindsightMemoryProvider()
assert bare._warning_callback is None
assert bare._platform == "cli"
16 changes: 8 additions & 8 deletions hindsight-integrations/hermes/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading