docs(plugins): fold unique guide content into the canonical author guide

Per review, docs/plugins.md was a parallel guide outside the published
navigation. Its genuinely unique material moves into
website/docs/developer-guide/plugins/index.md: the middleware
registration surface (all four VALID_MIDDLEWARE kinds with contracts and
chaining rules), the per-API-call request hooks, and the
allow_tool_override operator grant (which also fixes pre-existing text
implying override=True alone suffices for non-bundled plugins). Every
migrated claim was re-verified against current main; stale material
(deep-copy claim, approval-surface coverage, a hook that is not in
VALID_HOOKS on main) was corrected or dropped rather than copied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
This commit is contained in:
webdevtodayjason 2026-07-15 13:14:38 -05:00 committed by Teknium
parent 1636206ff0
commit 2711e41c4f
1 changed files with 71 additions and 0 deletions

View File

@ -740,6 +740,34 @@ auditable in `~/.hermes/logs/agent.log`. Plugins load after built-in
tools, so the registration order is correct: your handler replaces the
built-in one.
**Non-bundled plugins also need an operator grant.** For any plugin that
does not ship with Hermes core (user, project, or pip source),
`override=True` against an existing built-in tool additionally requires a
per-plugin opt-in in `config.yaml`:
```yaml
plugins:
entries:
my-plugin: # the plugin's registry key from `hermes plugins list`
allow_tool_override: true
```
Without the grant, `ctx.register_tool(..., override=True)` raises
`PluginToolOverrideError`; since `register()` exceptions are caught by the
loader, the plugin is disabled and Hermes continues. The gate exists
because an enabled plugin that silently replaces a privileged built-in
like `shell_exec` or `write_file` could intercept everything the model
routes through it. Bundled plugins are exempt: an override there is a
maintainer decision. If config cannot be loaded, the gate fails closed.
You normally never edit this key by hand. `hermes plugins enable <name>`
asks whether to grant the capability when enabling a non-bundled plugin
(defaulting to no), and the `--allow-tool-override` /
`--no-allow-tool-override` flags skip the prompt for scripted installs.
The same grant also gates `deregister()`: without it, a plugin cannot
remove a tool it does not own (which would otherwise be a way around the
override check).
### Register multiple hooks
```python
@ -761,6 +789,9 @@ Each hook is documented in full on the **[Event Hooks reference](/user-guide/fea
| [`post_tool_call`](/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | ignored |
| [`pre_llm_call`](/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) |
| [`post_llm_call`](/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored |
| `pre_api_request` | Before each raw provider API request (several per turn when the model calls tools) | `session_id: str, model: str, provider: str, base_url: str, api_mode: str, api_call_count: int, message_count: int, tool_count: int, approx_input_tokens: int, max_tokens: int, request: dict` | ignored |
| `post_api_request` | After each raw provider API request returns | `pre_api_request` fields plus `api_duration: float, finish_reason: str, response_model: str \| None, usage: dict, response: dict, assistant_content_chars: int, assistant_tool_call_count: int` | ignored |
| `api_request_error` | A provider API call raised | correlation fields plus `status_code: int \| None, retry_count: int \| None, max_retries: int \| None, retryable: bool \| None, reason: str \| None, error: dict, request: dict` | ignored |
| [`on_session_start`](/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored |
| [`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 |
@ -775,6 +806,8 @@ All callbacks should accept `**kwargs` for forward compatibility. If a hook call
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.
The **API request hooks** are observers for the raw provider request, one level below the per-turn `pre_llm_call` / `post_llm_call` pair: a single turn that calls tools makes several API requests, and these hooks fire around each one. They exist for observability plugins (tracing, cost accounting, latency dashboards). The `request` and `response` kwargs are sanitized, size-capped JSON views of the provider payload (sensitive keys redacted, long strings truncated, SDK objects normalized), and `usage` is a plain token-summary dict. Every payload carries the correlation fields `turn_id`, `api_request_id`, `task_id`, `session_id`, and `api_call_count`, so a plugin can stitch requests, tool calls, and turns together. `api_request_error` fires when a provider call raises and adds `status_code`, `retry_count` / `max_retries`, `retryable`, `reason`, and an `error` dict with `type` and `message`.
### `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.
@ -886,6 +919,44 @@ def register(ctx):
When multiple plugins return context from `pre_llm_call`, their outputs are joined with double newlines and appended to the user message together. The order follows plugin discovery order (alphabetical by plugin directory name).
### Middleware: change what happens
Hooks observe the agent loop (with the few documented steering shapes above). **Middleware changes what happens**: request middleware rewrites the effective payload before anything downstream sees it, and execution middleware wraps the actual call. Register it from the same `register(ctx)` entry point:
```python
def cap_find_output(tool_name, args, **kwargs):
"""Rewrite terminal find commands to cap their output."""
command = args.get("command", "")
if tool_name == "terminal" and command.startswith("find "):
return {
"args": {**args, "command": command + " | head -100"},
"source": "my-plugin",
"reason": "cap find output",
}
return None # leave the call unchanged
def register(ctx):
ctx.register_middleware("tool_request", cap_find_output)
```
The canonical list of kinds is `VALID_MIDDLEWARE` in `hermes_cli/middleware.py`:
| Kind | Receives | Return contract |
|------|----------|-----------------|
| `tool_request` | `tool_name`, `args`, `original_args`, context kwargs | Return `{"args": {...}}` to replace the effective tool arguments before hooks, guardrails, approvals, and execution see them. Return `None` to leave the call unchanged. |
| `llm_request` | `request`, `original_request`, context kwargs | Return `{"request": {...}}` to replace the effective provider kwargs before Hermes sends them. |
| `tool_execution` | the payload plus `next_call` | Wraps tool execution. Call `next_call(payload)` exactly once to run the downstream chain (or skip it to short-circuit) and return the result. |
| `llm_execution` | the payload plus `next_call` | Same shape, wrapping the provider call. |
**Rules that matter in practice:**
- Request middleware chains: each callback sees the payload as rewritten by earlier callbacks, while `original_args` / `original_request` always carries the pre-middleware copy. Payloads are copied between callbacks, so mutate freely.
- You can include `source`, `reason`, and `name` strings in the returned dict. They land in the middleware trace, which downstream observer hooks receive as the `middleware_trace` kwarg.
- `next_call` in execution middleware is **single-use**. Calling it twice raises, because it would re-run the provider or tool.
- A middleware callback that raises is logged and skipped; the chain continues. A downstream failure raised after your `next_call` propagates as itself. Middleware can never break the base runtime path.
- Middleware payloads carry `middleware_schema_version` (`hermes.middleware.v1`) alongside the observer telemetry fields.
- Unknown kinds register with a warning instead of failing, so a plugin written against a newer Hermes still loads on an older one.
### Register CLI commands
Plugins can add their own `hermes <plugin>` subcommand tree: