Skip to content

fix(embed): stop the HTTP probe clearing its caller's event loop, and make the Hermes gate test our plugin - #4575

Merged
nicoloboschi merged 4 commits into
mainfrom
hermes-plugin-transition
Sep 21, 2026
Merged

nicoloboschi merged 4 commits into
mainfrom
hermes-plugin-transition

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

The bug this found

hindsight-embed's probe_get() ran asyncio.run() on the caller's thread. asyncio.run doesn't just close the loop it creates — it leaves the thread with no current event loop. Loop state is per-thread, so a library helper doing that sabotages any sync caller that probes between its own calls: hindsight-client._run_async then finds no loop, builds a fresh one, and issues the next request through an aiohttp session still bound to the old one.

Same script, two builds:

PyPI 0.9.2 (works)                 working tree 0.10.0 (fails)
loop=5106485712  _ensure_started   no loop
loop=5106485712  delete_bank       loop=13295694816
loop=5106485712  create_bank       loop=13295696304   <- different loop
RETAIN OK                          RuntimeError: Timeout context manager
                                   should be used inside a task

That is exactly the Hermes memory plugin in local_embedded mode, where a daemon health probe sits between two client calls — embedded memory is broken on main today. Introduced by #4318 (httpx -> aiohttp), after the last release, which is why it reproduces against working-tree wheels but not PyPI.

Fix: the probe always runs on its own loop in a helper thread, never the caller's. That deletes the existing special case rather than adding one, and fixes every caller at the shared function.

The regression test pins the invariant (the caller's loop survives a probe). I verified it actually catches the bug by reverting the fix — it fails with the exact no current event loop error.

Why it went unnoticed, and the gate change that surfaced it

test-hermes-compat.sh has never had an LLM key in CI, so the retain path it guards had literally never executed. And since the provider left the Hermes tree, the script was testing Hermes' bundled copy — bundled wins on a name collision, and steps 4-6 never ran a line of plugin code.

  • Step 3 (new) — removes the bundled provider, installs this checkout's plugin, runs hermes plugins validate (the catalog admission gate, so a change that would be rejected upstream fails here instead of in their review queue), and asserts which copy loaded. Also means the script keeps working the day Nous drops the bundle.
  • Step 7 (new) — drives the MemoryProvider contract agent_init uses: is_available -> initialize -> sync_turn -> prefetch -> recall_status -> hindsight_recall -> on_session_end -> shutdown. Polls for extraction rather than sleeping, so a slow runner doesn't flake.
  • Step 5 — additionally asserts all four Hindsight distributions are still the locally built wheels. The plugin self-installs hindsight-all from PyPI when local_embedded has no runtime (right for a user, fatal here): it would swap the branch under review for a published release and leave the gate passing on code nobody changed.

CI: runs on integrations-hermes changes (a plugin edit triggered nothing before), passes an LLM key so the round-trips execute, timeout 45 -> 60.

Verification

Full script against hermes-agent@main, all 7 steps:

[3/7] validation passed; provider resolves to the external plugin
[5/7] all four Hindsight distributions are the locally built wheels
[6/7] daemon started, retained a memory, recalled (RecallResponse)
[7/7] sync_turn -> prefetch injected the retained memory -> recall_status -> 1 memory
=== PASS: hermes-agent@main is compatible with this Hindsight ===

Plus 226 hindsight-embed tests, lint clean.

Scope note

The branch started as a CI change and now carries the hindsight-embed fix too. I kept them together because the fix is what makes the gate pass — splitting means landing a knowingly-red CI change. Happy to split if preferred.

Follow-up, deliberately not fixed here

hindsight-client._run_async relies on thread-global loop state while holding a long-lived aiohttp session. Any third-party asyncio.run breaks it the same way; the durable fix is the client owning its own loop. Worth its own issue.

`probe_get` ran `asyncio.run` on the calling thread. That does not just close
the loop it creates — it leaves the thread with NO current event loop. Event
loop state is per-thread, so a library helper doing this quietly sabotages any
sync caller that probes between its own calls: hindsight-client's `_run_async`
then finds no current loop, builds a fresh one, and issues the next request
through an aiohttp session still bound to the old one, which fails with
"Timeout context manager should be used inside a task".

That is exactly the Hermes memory plugin in local_embedded mode, where a daemon
health probe sits between two client calls, so embedded memory is broken on main
today. Introduced by #4318 (httpx -> aiohttp), after the last release — which is
why it reproduces against working-tree wheels but not against PyPI.

The probe now always runs on its own loop in a helper thread, deleting the
special case rather than adding one. The regression test pins the invariant (the
caller's loop survives a probe) and fails on the previous implementation.

test(hermes): make the compat gate exercise the plugin, not Hermes' copy

The provider left the Hermes tree, but the script still tested Hermes' bundled
copy: bundled wins on a name collision, and steps 4-6 never executed a line of
plugin code. Two new steps close that.

Step 3 removes the bundled provider, installs this checkout's plugin into
$HERMES_HOME/plugins, runs `hermes plugins validate` (the catalog admission
gate, so a change that would be rejected upstream fails here instead of in their
review queue), and asserts the loaded provider resolves to the external
directory. It also means the script keeps working the day Nous drops the bundle.

Step 7 drives the MemoryProvider contract agent_init uses — is_available,
initialize, sync_turn, prefetch, recall_status, the recall tool, on_session_end,
shutdown — polling for extraction rather than sleeping, so a slow runner does
not flake.

Step 5 additionally asserts all four Hindsight distributions are still the
locally built wheels. The plugin self-installs hindsight-all from PyPI when
local_embedded has no runtime, which is right for a user and fatal here: it
would swap the branch under review for a published release and leave the script
passing on code nobody changed.

CI: run on integrations-hermes changes (a plugin edit ran nothing before), pass
an LLM key so the round-trips actually execute — they never had one, which is
why the bug above went unnoticed — and raise the timeout for the added step.
Step 7 exits early when there is no LLM key, but the caller printed the same
"round-trips a turn through Hermes' MemoryProvider contract" line either way —
so a fork PR, which never has secrets, reported coverage it had not run.

The step now exits 3 for "loaded fine, round-trip skipped" and the caller prints
a line that says so. Any other non-zero status still propagates and fails the
script.
`./scripts/hooks/lint.sh` reformats these on a clean checkout of main, so the
drift was already there and every branch that runs lint picks it up. Formatting
only — no behaviour change.
`hindsight-docs/docs-integrations/hermes.md` changed in dc75038 without
re-running ./scripts/generate-docs-skill.sh, so verify-generated-files has been
red on main since: the job regenerates every derived file and fails on any diff.

Generated output only.
@nicoloboschi
nicoloboschi merged commit 272c749 into main Sep 21, 2026
94 of 113 checks passed
@nicoloboschi
nicoloboschi deleted the hermes-plugin-transition branch September 21, 2026 11:17
allyblockcast Bot added a commit to Blockcast/hindsight that referenced this pull request Oct 18, 2026
…26.9.21 (BLO-47316) (#33)

Hermes main moved the hindsight provider out of its tree and gated every core
dependency on Python 3.14, so #32 pinned the gate to v2026.9.21. Upstream
(vectorize-io/hindsight) already redesigned the gate for exactly this and it
passes against Hermes main (e.g. run job 117087267621, hermes-agent@848fc0ad,
2026-10-16). Port it rather than re-derive it:

- scripts/test-hermes-compat.sh from vectorize-io main (vectorize-io#4575, vectorize-io#4769, vectorize-io#4838):
  3.14 venv, default ref main, installs THIS checkout's
  hindsight-integrations/hermes as an external plugin, runs
  `hermes plugins validate`, asserts the loaded provider is ours, asserts the
  four Hindsight distributions are the locally built wheels, and drives the
  MemoryProvider contract (step 7; skipped with an explicit message when no
  LLM key is present).
- hindsight-embed _http_probe fix from vectorize-io#4575: 0.10.0's probe cleared the
  caller's event loop, breaking local_embedded between two client calls.
- test.yml job changes from vectorize-io#4575 (integrations-hermes trigger, 60m timeout,
  LLM key env).

No PM-runtime redesign is needed: the plugin is loaded from
$HERMES_HOME/plugins inside a venv where Hermes and this checkout's wheels
were co-resolved, which is the compatibility property the gate exists for.

Signed-off-by: PlatformSREEngineer <platformsreengineer@paperclip.blockcast.net>
Co-authored-by: PlatformSREEngineer <platformsreengineer@paperclip.blockcast.net>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant