mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
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:
parent
b6f03ab891
commit
8e4d2fd23f
2 changed files with 60 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue