From 8e4d2fd23fb27a665c73b36db3ccb8dbeab25440 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:54:40 -0700 Subject: [PATCH] docs(plugins): document acting from hooks via ctx.profile_name + dispatch_tool (#50352) Answers a recurring plugin-author question: how to read the active profile and drive Hermes from inside a hook callback when ctx._cli_ref is None (gateway, hermes chat -q, and kanban-spawned worker sessions). - Adds a 'Act from inside a hook' section to the plugin guide covering ctx.profile_name and ctx.dispatch_tool as the session-agnostic APIs, with a kanban_task_blocked example, and notes there is no in-process slash-command bridge for headless workers (shell out via the terminal tool instead). - Adds the three kanban lifecycle hooks to the hook reference table with their process semantics. - Pins the contract with a regression test: ctx.dispatch_tool invokes a tool handler with _cli_ref=None (worker/hook context). Requested by @Smithangshu on Discord. --- tests/hermes_cli/test_plugins.py | 33 ++++++++++++++++++++ website/docs/guides/build-a-hermes-plugin.md | 27 ++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 16e5785c88f..e84dda7a1f2 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1902,3 +1902,36 @@ class TestPluginContextProfileName: ctx = self._ctx() assert ctx._manager._cli_ref is None assert ctx.profile_name == "worker1" + + +class TestDispatchToolWithoutCliRef: + """ctx.dispatch_tool works in worker/hook contexts (no _cli_ref). + + This pins the contract the plugin docs rely on: a plugin can drive + tools from a hook callback even when running in the gateway or a + kanban-spawned worker session, where _cli_ref is None. + """ + + def test_dispatch_tool_invokes_handler_without_cli_ref(self): + from tools.registry import registry + + mgr = PluginManager() + assert mgr._cli_ref is None # worker/hook context + ctx = PluginContext(PluginManifest(name="test-plugin", source="user"), mgr) + + calls = [] + registry.register( + name="_test_dispatch_probe", + toolset="debugging", + schema={"name": "_test_dispatch_probe", "description": "probe", + "parameters": {"type": "object", "properties": {}}}, + handler=lambda args, **kw: calls.append((args, kw)) or '{"ok": true}', + ) + try: + result = ctx.dispatch_tool("_test_dispatch_probe", {"x": 1}) + assert result == '{"ok": true}' + assert calls and calls[0][0] == {"x": 1} + # parent_agent is not forced when there's no CLI agent to resolve. + assert calls[0][1].get("parent_agent") is None + finally: + registry.deregister("_test_dispatch_probe") diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index a48db94ff94..5793c89a9fb 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -597,11 +597,16 @@ Each hook is documented in full on the **[Event Hooks reference](/user-guide/fea | [`on_session_end`](/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored | | [`on_session_finalize`](/user-guide/features/hooks#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored | | [`on_session_reset`](/user-guide/features/hooks#on_session_reset) | Gateway swaps in a new session key (`/new`, `/reset`) | `session_id: str, platform: str` | ignored | +| `kanban_task_claimed` | A kanban task is claimed (dispatcher process, before the worker spawns) | `task_id: str, board: str \| None, assignee: str \| None, run_id: int \| None, profile_name: str` | ignored | +| `kanban_task_completed` | A kanban task completes (worker process) | `task_id, board, assignee, run_id, profile_name, summary: str \| None` | ignored | +| `kanban_task_blocked` | A kanban task is blocked (worker process) | `task_id, board, assignee, run_id, profile_name, reason: str \| None` | ignored | Most hooks are fire-and-forget observers — their return values are ignored. The exception is `pre_llm_call`, which can inject context into the conversation. All callbacks should accept `**kwargs` for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally. +The kanban lifecycle hooks fire **after** the board DB change commits, so a callback always sees durable state and can never hold the SQLite write lock. Because kanban workers run as separate `hermes -p chat -q` subprocesses, `kanban_task_claimed` fires in the **dispatcher** process while `kanban_task_completed` / `kanban_task_blocked` fire in the **worker** process — hook in the dispatcher to observe every transition centrally, or in the worker for per-task in-session context. + ### `pre_llm_call` context injection This is the only hook whose return value matters. When a `pre_llm_call` callback returns a dict with a `"context"` key (or a plain string), Hermes injects that text into the **current turn's user message**. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context. @@ -827,6 +832,28 @@ def register(ctx): This is the public, stable interface for tool dispatch from plugin commands. Plugins should not reach into `ctx._cli_ref.agent` or similar private state. +### Act from inside a hook (profile + tools) + +`ctx._cli_ref` is only populated in an **interactive CLI** session. It is `None` in the gateway, in non-interactive `hermes chat -q` runs, and in **kanban-spawned worker sessions** — so any plugin logic that reaches through `_cli_ref` silently no-ops in exactly those contexts. Two stable, session-agnostic APIs cover what hooks actually need: + +- **`ctx.profile_name`** — the active profile name (e.g. `"default"`, or the assignee profile in a kanban worker). Derived from `HERMES_HOME`, so it works everywhere with no `_cli_ref` dependency. +- **`ctx.dispatch_tool(name, args)`** — invoke any registered tool (built-in or plugin), including the `kanban_*` tools, `delegate_task`, `terminal`, `read_file`, etc. Works from hook callbacks regardless of which process the hook fires in. + +Together these let a kanban lifecycle hook observe a transition and act on the board without touching framework internals: + +```python +def register(ctx): + def on_blocked(*, task_id, reason=None, **kw): + # Runs in the worker process; ctx._cli_ref is None here. + ctx.dispatch_tool("kanban_comment", { + "task_id": task_id, + "comment": f"[{ctx.profile_name}] auto-noted block: {reason}", + }) + ctx.register_hook("kanban_task_blocked", on_blocked) +``` + +For running a full `hermes ` (e.g. `hermes kanban show`), shell out with the `terminal` tool via `ctx.dispatch_tool("terminal", {"command": "hermes kanban show ..."})` — there is no in-process slash-command bridge for headless worker sessions, and tools are the supported way to drive Hermes from a hook. + ### Handle Slack Block Kit button clicks Plugins that post Block Kit messages with interactive elements (buttons, overflow menus, datepickers, etc.) can register the click handlers directly with the Slack adapter — no monkey-patching of `slack_bolt.AsyncApp` required.