Skip to content

feat: add native Hermes Agent support - #1010

Open
ildunari wants to merge 3 commits into
mksglu:mainfrom
ildunari:feat/hermes-native-support
Open

ildunari wants to merge 3 commits into
mksglu:mainfrom
ildunari:feat/hermes-native-support

Conversation

@ildunari

Copy link
Copy Markdown

Summary

Add first-class Hermes Agent support using context-mode's existing MCP server and session pipeline rather than introducing a parallel storage implementation.

This adds:

  • a native Hermes Python plugin at the repository root (plugin.yaml + __init__.py) installable with hermes plugins install mksglu/context-mode --enable
  • Hermes platform detection, adapter loading, client-name mapping, exact MCP tool naming, hook formatting, and profile-aware storage
  • bounded, fail-open bridging from Hermes lifecycle hooks to context-mode hook hermes <event>
  • exact compaction continuity through Hermes' pre_llm_call(compaction_applied=...) signal
  • conservative oversized-result indexing through the canonical mcp__context_mode__ctx_index tool, with unique per-tool-call sources and replacement only after confirmed success
  • /ctx-stats, /ctx-doctor, and /ctx-search Hermes commands backed by the same MCP server
  • npm publication/version-sync coverage for the Hermes plugin files
  • installation and platform-support documentation

This is intended as a more complete native-integration path alongside the work discussed in #981: the MCP server remains the data plane, while the Hermes plugin is only the lifecycle/control-plane bridge.

Architecture

Hermes Agent
  ├─ native Python plugin hooks
  │    └─ context-mode hook hermes <event>
  │         └─ existing routing + SessionDB continuity pipeline
  └─ configured MCP server: context_mode
       └─ existing ctx_execute / ctx_index / ctx_search / stats / doctor tools

The integration does not register a Hermes memory provider or context engine. It uses public plugin hooks and PluginContext.dispatch_tool only.

Safety and failure behavior

  • Hook subprocesses have bounded timeouts and fail open.
  • Hermes cannot rewrite tool arguments at pre_tool_call, so context-mode modify decisions become enforceable blocks with routing guidance rather than silently running the original command.
  • Automatic result replacement is limited to a conservative read-only tool allowlist.
  • Every indexed result gets a unique source label using Hermes' tool_call_id (UUID fallback), preventing later calls from overwriting earlier indexed results.
  • MCP indexing runs through one bounded daemon dispatch lane. A slow or stuck dispatch returns the original result and cannot accumulate worker threads.
  • Context-mode's own MCP tools are exempt from result transformation to prevent recursion.
  • Storage follows $HERMES_HOME/context-mode (normally ~/.hermes/context-mode) so named/profile-scoped Hermes instances do not bleed state.

Install

npm install -g context-mode
hermes plugins install mksglu/context-mode --enable
mcp_servers:
  context_mode:
    command: context-mode
    args: []
    enabled: true

Verification

  • npm run typecheck
  • npm run build
  • npm test
    • 211 test files passed
    • 4,727 tests passed
    • 24 skipped
  • git diff --check
  • real Hermes PluginManager discovery/load with isolated HERMES_HOME
  • real pre_llm_call hook bridge created a profile-scoped SessionDB and injected the Hermes routing block
  • real MCP stdio smoke:
    • server initialized as context-mode
    • 11 tools discovered
    • required ctx_index, ctx_search, ctx_stats, and ctx_doctor present
    • ctx_index succeeded
    • ctx_search retrieved the indexed marker
  • independent adversarial review completed; two P1 findings and one P2 finding were fixed, followed by a narrow closure review with no remaining P0/P1

@CommanderTurtle

Copy link
Copy Markdown

This looks much more maintainable. Thanks for working with this. One thing of note, the alternative #981 writes to a direct buffer with up to 3KiB of output interception. Already as-is seems to not only show the /ctx-stats, doctor, etcetera. And, direct hooks to replace hermes' terminal commands to the sandbox.

One clarification: the warning about allowing the plugin to overwrite results refers to Hermes’ transform_tool_result authority. It does not automatically grant arbitrary file mutation, and both implementations use that permission. Mine redirects large output to files; yours has very neat indexing, and eligible read-only output with substitution of a retrieval pointer.

The native Sessions DB compatibility and "legacy" /compress compatibility (a frequently run hermes compression) is what sold me. Frequently I was finding myself having to remind Hermes that there exists a context-mode database, specifically after running /compress .. lol.

[Continue after system context compression occurred - note, context-mode data still persists if ever needed]

So. Yeah. Much more robust implementation. Thanks again. I will see how well this integrates with my librarian and skill retrieval tools and write back 👍

@CommanderTurtle

Copy link
Copy Markdown

Working well within runtimes thus far.

CI apparently failing. The Hermes integration itself was not failing.

Only test (windows-latest) failed; Ubuntu, macOS, and both OpenClaw E2E jobs passed. Failed Windows job

The test created this POSIX-only fake executable on every platform:

#!/bin/sh
printf '%s' '{"hookSpecificOutput":{"additionalContext":"continuity"}}'

Windows Python could not launch that extensionless shell script. The plugin correctly failed open, returned no continuity context, and the embedded Python assertion at line 31 failed. The three GitHub annotations were repetitions of that same failure—not three separate defects. The Node 20 Actions notice was only a warning.

(click to expand)
import { describe, it, expect } from "vitest";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { HermesAdapter } from "../../src/adapters/hermes/index.js";

const ROOT = resolve(__dirname, "../..");
describe("Hermes native adapter", () => {
  it("declares hooks with Hermes' public manifest field", () => {
    const manifest = readFileSync(resolve(ROOT, "plugin.yaml"), "utf8");
    expect(manifest).toContain("provides_hooks:");
    expect(manifest).not.toMatch(/^hooks:/m);
  });

  it("uses Hermes storage and normalizes public hook payloads", () => {
    const adapter = new HermesAdapter();
    expect(adapter.getSessionDir()).toBe(resolve(adapter.getConfigDir(), "context-mode", "sessions"));
    expect(adapter.parsePreToolUseInput({ tool_name: "terminal", args: { command: "pwd" }, session_id: "s" })).toMatchObject({ toolName: "terminal", toolInput: { command: "pwd" }, sessionId: "s" });
    expect(adapter.capabilities.canModifyOutput).toBe(true);
    expect(adapter.paradigm).toBe("python-plugin");
  });

  it("honors profile-scoped HERMES_HOME for MCP and hook storage", () => {
    const previous = process.env.HERMES_HOME;
    const home = mkdtempSync(resolve(tmpdir(), "hermes-home-"));
    process.env.HERMES_HOME = home;
    try {
      const adapter = new HermesAdapter();
      expect(adapter.getConfigDir()).toBe(home);
      expect(adapter.getSettingsPath()).toBe(resolve(home, "config.yaml"));
      expect(adapter.getSessionDir()).toBe(resolve(home, "context-mode", "sessions"));
    } finally {
      if (previous === undefined) delete process.env.HERMES_HOME;
      else process.env.HERMES_HOME = previous;
    }
  });

  it("loads as a real Python plugin, registers public hooks/commands, and fails open", () => {
    const binDir = mkdtempSync(resolve(tmpdir(), "context-mode-hermes-"));
    const windows = process.platform === "win32";
    const stub = resolve(binDir, windows ? "context-mode.cmd" : "context-mode");
    writeFileSync(stub, windows ? `@echo off\r
<nul set /p ={"hookSpecificOutput":{"additionalContext":"continuity"}}\r
exit /b 0\r
` : `#!/bin/sh
printf '%s' '{"hookSpecificOutput":{"additionalContext":"continuity"}}'
`);
    if (!windows) chmodSync(stub, 0o755);
    const harness = String.raw`
import importlib.util, json, pathlib, time
root=pathlib.Path(${JSON.stringify(ROOT)})
spec=importlib.util.spec_from_file_location("context_mode_hermes", root/"__init__.py")
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class C:
 def __init__(self): self.hooks={}; self.commands={}; self.calls=[]; self.delay=False
 def register_hook(self,n,f): self.hooks[n]=f
 def register_command(self,n,f,*a): self.commands[n]=f
 def dispatch_tool(self,n,a,**kw):
  self.calls.append((n,a,kw))
  if self.delay: time.sleep(0.2)
  return json.dumps({"success":True})
c=C(); m.register(c)
assert {"pre_tool_call","post_tool_call","pre_llm_call","on_session_end","on_session_finalize","on_session_reset","transform_tool_result"} <= set(c.hooks)
assert "post_llm_call" not in c.hooks and "on_session_start" not in c.hooks
assert {"ctx-stats","ctx-doctor","ctx-search"} == set(c.commands)
assert c.hooks["transform_tool_result"]("terminal", "small", session_id="s") is None
large="x"*17000
assert c.hooks["transform_tool_result"]("terminal", large, session_id="s") is None
marker=c.hooks["transform_tool_result"]("read_file", large, session_id="s", tool_call_id="call-a")
assert marker and "indexed 17000 bytes" in marker
marker2=c.hooks["transform_tool_result"]("read_file", large, session_id="s", tool_call_id="call-b")
assert marker2 and c.calls[-2][1]["source"] != c.calls[-1][1]["source"]
assert c.hooks["transform_tool_result"]("write_file", large, session_id="s") is None
c.delay=True; m._INDEX_TIMEOUT=0.01
started=time.monotonic()
assert c.hooks["transform_tool_result"]("read_file", large, session_id="s", tool_call_id="slow") is None
assert time.monotonic()-started < 0.1
r=c.hooks["pre_llm_call"](session_id="fresh", user_message="hello", is_first_turn=True, compaction_applied=False)
assert r == {"system_context":"continuity"}
r=c.hooks["pre_llm_call"](session_id="fresh", user_message="next", is_first_turn=False, compaction_applied=True)
assert r == {"system_context":"continuity"}
print("ok")
`;
    const run = spawnSync("python3", ["-c", harness], { encoding: "utf8", timeout: 10_000, env: { ...process.env, CONTEXT_MODE_EXECUTABLE: stub } });
    expect(run.status, run.stderr).toBe(0);
    expect(run.stdout.trim()).toBe("ok");
  });
});

Updated the test to use:

  • context-mode.cmd with exit /b 0 on Windows
  • The existing shell stub on Linux/macOS
  • chmod only on non-Windows platforms

Production plugin code was untouched. Python officially supports invoking Windows batch files without enabling shell=True, matching the real npm/Bun-installed context-mode.cmd path. Python subprocess documentation

Verification passed:

tests/adapters/hermes.test.ts: 4/4 passed
bun run typecheck: passed
git diff --check: passed

To push only the CI fix:

cd context-mode
git add tests/adapters/hermes.test.ts
git commit -m "test: use a native Hermes stub on Windows"
git push origin feat/hermes-native-support

That should rerun PR #1010 with the actual Windows execution model represented correctly.

@CommanderTurtle

Copy link
Copy Markdown

@ildunari , I added fixed hermes.test.ts in my last comment that can be committed to fix the windows-latest test. Then sync and CI can run

Else if @mksglu could simply adjust that file with replacement, should be ready to go

Windows Python cannot launch the POSIX-only extensionless stub, so the
plugin failed open and the embedded harness assertion failed. Write a
.cmd stub on win32 and keep the existing shell stub elsewhere.

Co-authored-by: ildunari <ildunari@users.noreply.github.com>
khepriclaw added a commit to khepriclaw/context-mode that referenced this pull request Sep 16, 2026
Continue downstream compatibility for ildunari's upstream PR mksglu#1010. Use the existing scanned subdirectory installer and native search argument metadata.
khepriclaw added a commit to khepriclaw/context-mode that referenced this pull request Sep 16, 2026
Resolve closure E1 using context-preserving async offloading, retaining the existing MCP lane and timeout semantics. Prove responsiveness through real gateway dispatch and CLI async result resolution. Continues ildunari upstream PR mksglu#1010.
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.

4 participants