From 4a2cbfebb959f959e927043529a06c3c6418b340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 23 Sep 2026 10:40:59 +0200 Subject: [PATCH 1/3] fix(hermes): port two post-handoff fixes and floor the deps at 0.10.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4640. Two fixes landed on hermes-agent's bundled copy after the 2026-09-17 handoff and never reached this tree. Users moved onto our copy by the catalog migration would have regressed on both. - Bounded append-mode turn buffer (hermes-agent 6e1de4850e6, their #62950). sync_turn now clears _session_turns after an append retain instead of moving the watermark to its length. The retain job snapshots its own content and append retains only ever read the un-retained tail, so keeping the turns pinned the whole session in memory on long-running gateway sessions. Overwrite mode is untouched — it resends the full session each retain and must keep every turn. This also removes a duplicate write: on_session_switch flushes the WHOLE buffer under the old document id, so after an append retain it re-shipped turns that had already been stored. test_session_switch_starts_a_new_document was pinning that second retain; it now expects one per session. - Gated root warning (hermes-agent cd3de040ab9). initialize() records warning_callback/platform and _start_embedded_daemon routes the "cannot run as root" notice through the host's sink, falling back to gateway.warning_notifications.render_notification, instead of an unconditional stderr print. That is what lets users opt out of user-channel warnings on messaging platforms. Also drops the `hooks:` key from plugin.yaml. Hermes reads `provides_hooks`, and the bundled copy dropped the declaration in 72ee40fa680 because memory providers get their lifecycle calls through the provider interface, not registered hooks. Separately, floors the declared deps at >=0.10.1. hindsight-embed 0.10.0 (on PyPI 2026-09-14 to 2026-09-21) shipped a probe that ran asyncio.run on the caller's thread, clearing its event loop, so the next client call hit an aiohttp session bound to a dead loop and local_embedded failed outright. Anyone who installed in that window is pinned to the broken release until a floor moves them off it; _MIN_CLIENT_VERSION moves with it so _maybe_upgrade_client() pulls the client forward on session start rather than waiting for a `hermes update`. Both behaviour fixes have a regression test, each verified to fail on the pre-fix code. --- hindsight-integrations/hermes/README.md | 9 ++- hindsight-integrations/hermes/__init__.py | 35 ++++++++-- hindsight-integrations/hermes/plugin.yaml | 4 +- hindsight-integrations/hermes/pyproject.toml | 11 ++- hindsight-integrations/hermes/settings.py | 6 +- .../hermes/tests/test_provider.py | 67 ++++++++++++++++++- hindsight-integrations/hermes/uv.lock | 16 ++--- 7 files changed, 125 insertions(+), 23 deletions(-) diff --git a/hindsight-integrations/hermes/README.md b/hindsight-integrations/hermes/README.md index 34d176ae2..3e50a9c5a 100644 --- a/hindsight-integrations/hermes/README.md +++ b/hindsight-integrations/hermes/README.md @@ -186,7 +186,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 diff --git a/hindsight-integrations/hermes/__init__.py b/hindsight-integrations/hermes/__init__.py index b42ef87e1..1da6529b3 100644 --- a/hindsight-integrations/hermes/__init__.py +++ b/hindsight-integrations/hermes/__init__.py @@ -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: @@ -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() @@ -1090,11 +1097,21 @@ 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) + cb = getattr(self, "_warning_callback", None) + if cb is not None: + cb(msg) + else: + from gateway.warning_notifications import render_notification + + render_notification( + lambda: print(f" ⚠ {msg}", file=sys.stderr, flush=True), + platform=getattr(self, "_platform", "cli"), + ) self._mode = "disabled" return spawn_context_thread(self._daemon_start_worker, name="hindsight-daemon-start").start() @@ -1415,7 +1432,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.""" diff --git a/hindsight-integrations/hermes/plugin.yaml b/hindsight-integrations/hermes/plugin.yaml index 9dfa763af..b22cc5a35 100644 --- a/hindsight-integrations/hermes/plugin.yaml +++ b/hindsight-integrations/hermes/plugin.yaml @@ -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 diff --git a/hindsight-integrations/hermes/pyproject.toml b/hindsight-integrations/hermes/pyproject.toml index 42afb3b4d..cedacd30f 100644 --- a/hindsight-integrations/hermes/pyproject.toml +++ b/hindsight-integrations/hermes/pyproject.toml @@ -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. diff --git a/hindsight-integrations/hermes/settings.py b/hindsight-integrations/hermes/settings.py index 0cb7ca9ca..a02a43134 100644 --- a/hindsight-integrations/hermes/settings.py +++ b/hindsight-integrations/hermes/settings.py @@ -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 diff --git a/hindsight-integrations/hermes/tests/test_provider.py b/hindsight-integrations/hermes/tests/test_provider.py index 96c9f2454..8ad928e27 100644 --- a/hindsight-integrations/hermes/tests/test_provider.py +++ b/hindsight-integrations/hermes/tests/test_provider.py @@ -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.") @@ -115,11 +121,68 @@ 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() diff --git a/hindsight-integrations/hermes/uv.lock b/hindsight-integrations/hermes/uv.lock index f8a27440d..da7064ac4 100644 --- a/hindsight-integrations/hermes/uv.lock +++ b/hindsight-integrations/hermes/uv.lock @@ -302,8 +302,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "hindsight-client", specifier = ">=0.6.1,<1" }, - { name = "hindsight-embed", specifier = ">=0.6.1,<1" }, + { name = "hindsight-client", specifier = ">=0.10.1,<1" }, + { name = "hindsight-embed", specifier = ">=0.10.1,<1" }, ] [package.metadata.requires-dev] @@ -311,7 +311,7 @@ dev = [{ name = "pytest", specifier = ">=8.0" }] [[package]] name = "hindsight-client" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -321,22 +321,22 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/45/3ad3f2207948ae07d386616c76f19bcdcc442a969dcdaea23bf05428d508/hindsight_client-0.10.0.tar.gz", hash = "sha256:adc99e7d5932acd7e2ac90eacd9a49ace7b0dec0b3422e3116e2fede07d0117c", size = 181089, upload-time = "2026-09-14T10:46:06.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/81/03d22890aa5fa8b09fa1c290814cf0ff716bd5c6b537305458467092e5a1/hindsight_client-0.10.1.tar.gz", hash = "sha256:764c4326c14123c5c9222f21ae5ebe62ef2323c60c8907b35667f5f82924c81d", size = 192363, upload-time = "2026-09-21T15:00:52.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/ad/d9656c81ddcb3fcf64bfa8c808f87bb152acca52e2baf2a608f94ef40614/hindsight_client-0.10.0-py3-none-any.whl", hash = "sha256:b16ac3f8b13fc2c35f92ed57129021e53c548a26ee0354015f9ef1394f678e06", size = 411602, upload-time = "2026-09-14T10:46:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9b/aef41711ea4ea419386a10d7b5b52cebd9aac380127897629508ddff12b9/hindsight_client-0.10.1-py3-none-any.whl", hash = "sha256:04e03353a0f9dcfa7b1b1000cccc8f077a93a788b27af62cc5e014b2dd3b960c", size = 422723, upload-time = "2026-09-21T15:00:51.121Z" }, ] [[package]] name = "hindsight-embed" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/9a/832bcf1cd89875cc94f32863c110710120aed434604f928792c0c15bf724/hindsight_embed-0.10.0.tar.gz", hash = "sha256:0ad377204307ddc7835859668afa7a520d6efb84fc061d3095a2ed4948661df1", size = 710085, upload-time = "2026-09-14T10:46:45.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/e3/85af37fc3b35c24182fb24fa831fd36ad601331eb7e590a3d330cfb44767/hindsight_embed-0.10.1.tar.gz", hash = "sha256:1617b6f24dad1709abed0d5f46e44d99cfcf9602710be7f55ac81fea1a758338", size = 713871, upload-time = "2026-09-21T15:01:34.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/22/4a30c9844ebe0ebec197602a7c2ec47f97863d29c1c02efc1f34994dddb2/hindsight_embed-0.10.0-py3-none-any.whl", hash = "sha256:2831ce2d1d7c927c55646f748bb0113342fdf5a2bea9dcb15273d29f64326502", size = 832872, upload-time = "2026-09-14T10:46:43.87Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ee/c83d04c9825f26f19bbe67702d68902576e62bb0e75cd4123846e4ad2106/hindsight_embed-0.10.1-py3-none-any.whl", hash = "sha256:6cf2294ea94113dcf13b2ca61010d571673d2bca6245cd2c3a714fd760cdb8c0", size = 835478, upload-time = "2026-09-21T15:01:32.814Z" }, ] [[package]] From 0efc4e97514b9443bb0b3496cf7f53be1c0d6200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 23 Sep 2026 11:11:29 +0200 Subject: [PATCH 2/3] chore(hermes): drop our copy of the catalog entry teknium authored the real entry in NousResearch/hermes-agent#119767, so plugin-catalog-entry.yaml is a second copy of a file we do not own and would silently drift from the one that actually ships. The README now points at theirs and states the consequence that matters: changes here reach nobody until that pin moves. --- hindsight-integrations/hermes/README.md | 11 ++++-- .../hermes/plugin-catalog-entry.yaml | 37 ------------------- 2 files changed, 7 insertions(+), 41 deletions(-) delete mode 100644 hindsight-integrations/hermes/plugin-catalog-entry.yaml diff --git a/hindsight-integrations/hermes/README.md b/hindsight-integrations/hermes/README.md index 3e50a9c5a..157242698 100644 --- a/hindsight-integrations/hermes/README.md +++ b/hindsight-integrations/hermes/README.md @@ -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 diff --git a/hindsight-integrations/hermes/plugin-catalog-entry.yaml b/hindsight-integrations/hermes/plugin-catalog-entry.yaml deleted file mode 100644 index fc8f2d3f5..000000000 --- a/hindsight-integrations/hermes/plugin-catalog-entry.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Hermes plugin-catalog entry for this provider. -# -# Not read by anything here — it is the file WE PR into NousResearch/hermes-agent as -# `plugin-catalog/hindsight.yaml`. Submitting the catalog entry is the receiving -# maintainer's job (ours), not Nous'. -# -# It must land BEFORE (or with) their removal of the bundled `plugins/memory/hindsight/`: -# `hermes_cli/memory_provider_migration.py` looks the provider up by name in the catalog -# when it can no longer find it on disk, and without an entry every existing user gets -# -# ⚠ Memory provider 'hindsight' is configured but not installed and not in the plugin catalog. -# -# instead of an automatic install on `hermes update`. -# -# `repo`/`subdir` point at this monorepo — the live source for the provider. -# -# `sha` must be a 40-char commit on vectorize-io/hindsight main that contains the -# provider directory this entry points at — the catalog only installs reviewed pins. -# Bump it (and `version`) in a follow-up PR whenever the provider changes. -name: hindsight -repo: https://github.com/vectorize-io/hindsight -sha: 0000000000000000000000000000000000000000 # set to the release commit before opening the PR -subdir: hindsight-integrations/hermes -version: "1.0.0" -description: Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Cloud, local embedded, or local external. -maintainer: vectorize-io -tier: community -category: memory -docs_url: https://hindsight.vectorize.io/sdks/integrations/hermes -# Memory providers surface their tools through `get_tool_schemas()`, not `register(ctx)`, -# so the capability probe records zero registrations. Declaring any here would fail -# `hermes plugins validate`'s declared-vs-registered check. -capabilities: - provides_tools: [] - provides_hooks: [] - provides_middleware: [] - requires_env: [] From 6a6d131823c75cc2a489ce3a9d37e9f3c06bf6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 23 Sep 2026 11:14:50 +0200 Subject: [PATCH 3/3] review: read the warning sink directly and pin its defaults __init__ now guarantees _warning_callback/_platform, so the getattr fallbacks carried over from the upstream port were dead defensiveness. Reading the attributes directly makes the __init__ defaults load-bearing, so a test pins them: availability probes construct a provider without ever calling initialize(), and _start_embedded_daemon reads both. --- hindsight-integrations/hermes/__init__.py | 7 +++---- hindsight-integrations/hermes/tests/test_provider.py | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/hindsight-integrations/hermes/__init__.py b/hindsight-integrations/hermes/__init__.py index 1da6529b3..f078939c1 100644 --- a/hindsight-integrations/hermes/__init__.py +++ b/hindsight-integrations/hermes/__init__.py @@ -1102,15 +1102,14 @@ def _start_embedded_daemon(self) -> None: # 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): - cb = getattr(self, "_warning_callback", None) - if cb is not None: - cb(msg) + 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=getattr(self, "_platform", "cli"), + platform=self._platform, ) self._mode = "disabled" return diff --git a/hindsight-integrations/hermes/tests/test_provider.py b/hindsight-integrations/hermes/tests/test_provider.py index 8ad928e27..b846542b6 100644 --- a/hindsight-integrations/hermes/tests/test_provider.py +++ b/hindsight-integrations/hermes/tests/test_provider.py @@ -186,3 +186,11 @@ def test_root_warning_goes_through_the_hosts_warning_callback(provider, monkeypa 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"