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.
This commit is contained in:
Teknium 2026-06-21 12:54:40 -07:00 committed by GitHub
parent b6f03ab891
commit 8e4d2fd23f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 0 deletions

View file

@ -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")

View file

@ -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 <profile> 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 <subcommand>` (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.