Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
@@ -322,9 +322,98 @@ optional_env:
|
||||
|
||||
Bare-string entries (`- MY_PLATFORM_TOKEN`) still work — they get a generic description auto-derived from the plugin's `label`. If a hardcoded entry for the same var already exists in `OPTIONAL_ENV_VARS`, it wins (back-compat); the plugin.yaml form acts as the fallback.
|
||||
|
||||
## Platform-Specific Slow-LLM UX
|
||||
|
||||
Some platforms have constraints that change how a slow LLM response should be presented:
|
||||
|
||||
- **LINE** issues a single-use *reply token* that expires roughly 60 seconds after the inbound event. Replying with that token is free; falling back to the metered Push API is not. If the LLM hasn't finished by the deadline, the choice is "burn paid Push quota" or "do something cleverer with the reply token before it expires."
|
||||
- **WhatsApp** marks a session inactive after 24h, after which only template messages are accepted.
|
||||
- **SMS** has no concept of typing indicators or progressive updates — long responses just look like the bot is offline.
|
||||
|
||||
These are real constraints the base `BasePlatformAdapter` can't anticipate. The plugin surface intentionally leaves the room for an adapter to layer platform-specific UX on top of the base typing loop without expanding the kwarg list.
|
||||
|
||||
### Pattern: subclass `_keep_typing` to layer mid-flight UX
|
||||
|
||||
`BasePlatformAdapter._keep_typing` is the typing-indicator heartbeat — it runs as a background task while the LLM is generating, and is cancelled when the response is delivered. To layer a platform-specific behavior at a threshold (e.g. send a "still thinking" bubble at 45s), override `_keep_typing` in your adapter, schedule your own task alongside `super()._keep_typing()`, and tear it down in `finally`:
|
||||
|
||||
```python
|
||||
class LineAdapter(BasePlatformAdapter):
|
||||
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
|
||||
if self.slow_response_threshold <= 0:
|
||||
await super()._keep_typing(chat_id, *args, **kwargs)
|
||||
return
|
||||
|
||||
async def _fire_at_threshold() -> None:
|
||||
try:
|
||||
await asyncio.sleep(self.slow_response_threshold)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
# Platform-specific work here — for LINE, send a Template
|
||||
# Buttons "Get answer" bubble using the cached reply token
|
||||
# so the user can fetch the cached response later via a
|
||||
# fresh (free) reply token from the postback callback.
|
||||
await self._send_slow_response_button(chat_id)
|
||||
|
||||
side_task = asyncio.create_task(_fire_at_threshold())
|
||||
try:
|
||||
await super()._keep_typing(chat_id, *args, **kwargs)
|
||||
finally:
|
||||
if not side_task.done():
|
||||
side_task.cancel()
|
||||
try:
|
||||
await side_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **Always `await super()._keep_typing(...)`.** The typing heartbeat is independently useful — don't replace it, layer on top of it.
|
||||
- **Tear down the side task in `finally`.** When the LLM finishes (or `/stop` cancels the run), the gateway cancels the typing task. Your side task must observe that cancellation too, otherwise it lingers and may fire after the response was already delivered.
|
||||
- **Pair with `interrupt_session_activity`** to resolve any orphan UX state when the user issues `/stop`. For LINE, this means transitioning the postback cache entry from `PENDING` to `ERROR` so the persistent "Get answer" button delivers a "Run was interrupted" message instead of looping.
|
||||
|
||||
### Pattern: subclass `send` to route through a cache instead of sending immediately
|
||||
|
||||
If your slow-response UX caches the response for later retrieval (LINE's postback flow), your `send` override needs to recognize three modes:
|
||||
|
||||
1. **Pending postback active for this chat** → cache the response under the request_id, don't send anything visible.
|
||||
2. **System busy-ack** (`⚡ Interrupting`, `⏳ Queued`, `⏩ Steered`) → bypass the cache and send visibly so the user sees the gateway's response to their input.
|
||||
3. **Normal response** → send via reply-token-or-push as usual.
|
||||
|
||||
```python
|
||||
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
|
||||
if _is_system_bypass(content):
|
||||
return await self._send_text_chunks(chat_id, content, force_push=False)
|
||||
pending_rid = self._pending_buttons.get(chat_id)
|
||||
if pending_rid:
|
||||
self._cache.set_ready(pending_rid, content)
|
||||
return SendResult(success=True, message_id=pending_rid)
|
||||
return await self._send_text_chunks(chat_id, content, force_push=False)
|
||||
```
|
||||
|
||||
`_SYSTEM_BYPASS_PREFIXES` are the gateway's own busy-acknowledgment prefixes (`⚡`, `⏳`, `⏩`, `💾`). Always let those through visibly, regardless of cached UX state.
|
||||
|
||||
### When this pattern is appropriate
|
||||
|
||||
Use the typing-loop override approach when:
|
||||
|
||||
- The platform's outbound API has a hard time-window constraint (single-use reply token, expiring sticky session, etc.) AND
|
||||
- A *visible mid-flight bubble* is acceptable UX on that platform.
|
||||
|
||||
Use the simpler `slow_response_threshold = 0` always-Push path when:
|
||||
|
||||
- The platform doesn't have a meaningful free vs. paid distinction, OR
|
||||
- The user community prefers "loading… loading… DONE" silence-then-response over an interactive intermediate bubble.
|
||||
|
||||
LINE supports both: the threshold defaults to 45s for free postback fetch, and `LINE_SLOW_RESPONSE_THRESHOLD=0` reverts to "always Push fallback."
|
||||
|
||||
### Reference Implementation
|
||||
|
||||
See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies.
|
||||
See `plugins/platforms/line/adapter.py` for the full LINE postback implementation — a `RequestCache` state machine (`PENDING → READY → DELIVERED`, plus `ERROR` for `/stop`), a `_keep_typing` override that fires the Template Buttons bubble at threshold, a `send` override that routes through the cache, and an `interrupt_session_activity` override that resolves orphan PENDING entries.
|
||||
|
||||
### Reference Implementations (Plugin Path)
|
||||
|
||||
See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. `plugins/platforms/teams/` covers Bot Framework / Adaptive Cards, `plugins/platforms/google_chat/` covers OAuth-based REST APIs, and `plugins/platforms/line/` covers webhook-driven Messaging APIs with platform-specific slow-LLM UX.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
title: "Plugin LLM Access"
|
||||
description: "Run any LLM call from inside a plugin via ctx.llm — chat or structured, sync or async. Host-owned auth, fail-closed trust gate, optional JSON Schema validation."
|
||||
---
|
||||
|
||||
# Plugin LLM Access
|
||||
|
||||
`ctx.llm` is the supported way for a plugin to make an LLM call.
|
||||
Chat completion, structured extraction, sync, async, with or without
|
||||
images — same surface, same trust gate, same host-owned credentials.
|
||||
|
||||
Plugins reach for this when they need to do something that involves
|
||||
the model but isn't part of the agent's conversation. A hook that
|
||||
rewrites a tool error into something a non-engineer can read. A
|
||||
gateway adapter that translates an inbound message before queuing
|
||||
it. A slash command that summarises a long paste. A scheduled job
|
||||
that scores yesterday's activity and writes one line to a status
|
||||
board. A pre-filter that decides whether a message is worth waking
|
||||
the agent up for at all.
|
||||
|
||||
These are jobs the agent shouldn't be in the loop on. They want one
|
||||
LLM call, a typed answer, and to be done.
|
||||
|
||||
## The smallest possible call
|
||||
|
||||
```python
|
||||
result = ctx.llm.complete(messages=[{"role": "user", "content": "ping"}])
|
||||
return result.text
|
||||
```
|
||||
|
||||
That's the whole API in one line. No keys, no provider config, no
|
||||
SDK initialisation. The plugin runs against whatever provider and
|
||||
model the user is currently using — when they switch providers, the
|
||||
plugin follows them automatically.
|
||||
|
||||
## A more complete chat example
|
||||
|
||||
```python
|
||||
result = ctx.llm.complete(
|
||||
messages=[
|
||||
{"role": "system", "content": "Rewrite errors as one short sentence a non-engineer can act on."},
|
||||
{"role": "user", "content": traceback_text},
|
||||
],
|
||||
max_tokens=64,
|
||||
purpose="hooks.error-rewrite",
|
||||
)
|
||||
return result.text
|
||||
```
|
||||
|
||||
`purpose` is a free-form audit string — it shows up in `agent.log`
|
||||
and in `result.audit` so operators can see which plugin made which
|
||||
call. Optional but recommended for anything that fires often.
|
||||
|
||||
## Structured output
|
||||
|
||||
When the plugin needs a typed answer, switch to the structured lane:
|
||||
|
||||
```python
|
||||
result = ctx.llm.complete_structured(
|
||||
instructions="Score this support reply for urgency (0–1) and pick a category.",
|
||||
input=[{"type": "text", "text": message_body}],
|
||||
json_schema=TRIAGE_SCHEMA,
|
||||
purpose="support.triage",
|
||||
temperature=0.0,
|
||||
max_tokens=128,
|
||||
)
|
||||
|
||||
if result.parsed["urgency"] > 0.8:
|
||||
await dispatch_to_oncall(result.parsed["category"], message_body)
|
||||
```
|
||||
|
||||
The host requests JSON output from the provider, parses it locally
|
||||
as a fallback, validates against your schema if `jsonschema` is
|
||||
installed, and hands back a Python object on `result.parsed`. If the
|
||||
model couldn't produce valid JSON, `result.parsed` is `None` and
|
||||
`result.text` carries the raw response.
|
||||
|
||||
## What this lane gives you
|
||||
|
||||
* **One call, four shapes.** `complete()` for chat,
|
||||
`complete_structured()` for typed JSON, `acomplete()` and
|
||||
`acomplete_structured()` for asyncio. Same arguments, same result
|
||||
objects.
|
||||
* **Host-owned credentials.** OAuth tokens, refresh flows, the
|
||||
credential pool, per-task aux overrides — every credential
|
||||
concept Hermes already has applies. The plugin never sees a
|
||||
token; the host attributes the call back through `result.audit`.
|
||||
* **Bounded.** Single sync or async call. No streaming, no tool
|
||||
loops, no conversation state to manage. State the input, get the
|
||||
result, return.
|
||||
* **Fail-closed trust.** A plugin you've never configured cannot
|
||||
pick its own provider, model, agent, or stored credential. The
|
||||
default posture is "use what the user is using." Operators opt in
|
||||
to specific overrides, per plugin, in `config.yaml`.
|
||||
|
||||
## Quick start
|
||||
|
||||
Two complete plugins below — one chat, one structured. Both ship
|
||||
inside a single `register(ctx)` function and need zero outside
|
||||
configuration to run against whatever model the user has active.
|
||||
|
||||
### Chat completion — `/tldr`
|
||||
|
||||
```python
|
||||
def register(ctx):
|
||||
ctx.register_command(
|
||||
name="tldr",
|
||||
handler=lambda raw: _tldr(ctx, raw),
|
||||
description="Summarise the supplied text in one paragraph.",
|
||||
args_hint="<text>",
|
||||
)
|
||||
|
||||
|
||||
def _tldr(ctx, raw_args: str) -> str:
|
||||
text = raw_args.strip()
|
||||
if not text:
|
||||
return "Usage: /tldr <text to summarise>"
|
||||
result = ctx.llm.complete(
|
||||
messages=[
|
||||
{"role": "system",
|
||||
"content": "Summarise the user's text in one tight paragraph. No preamble."},
|
||||
{"role": "user", "content": text},
|
||||
],
|
||||
max_tokens=256,
|
||||
temperature=0.3,
|
||||
purpose="tldr",
|
||||
)
|
||||
return result.text
|
||||
```
|
||||
|
||||
`result.text` is the model's response; `result.usage` carries token
|
||||
counts; `result.provider` and `result.model` carry attribution.
|
||||
|
||||
### Structured extraction — `/paste-to-tasks`
|
||||
|
||||
```python
|
||||
def register(ctx):
|
||||
ctx.register_command(
|
||||
name="paste-to-tasks",
|
||||
handler=lambda raw: _paste_to_tasks(ctx, raw),
|
||||
description="Turn freeform meeting notes into structured tasks.",
|
||||
args_hint="<text>",
|
||||
)
|
||||
|
||||
|
||||
_TASKS_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"action": {"type": "string"},
|
||||
"due": {"type": "string", "description": "ISO date or empty"},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["tasks"],
|
||||
}
|
||||
|
||||
|
||||
def _paste_to_tasks(ctx, raw_args: str) -> str:
|
||||
if not raw_args.strip():
|
||||
return "Usage: /paste-to-tasks <meeting notes>"
|
||||
result = ctx.llm.complete_structured(
|
||||
instructions=(
|
||||
"Extract concrete action items from these meeting notes. "
|
||||
"One task per actionable line. If no owner is named, leave 'owner' blank."
|
||||
),
|
||||
input=[{"type": "text", "text": raw_args}],
|
||||
json_schema=_TASKS_SCHEMA,
|
||||
schema_name="meeting.tasks",
|
||||
purpose="paste-to-tasks",
|
||||
temperature=0.0,
|
||||
max_tokens=512,
|
||||
)
|
||||
if result.parsed is None:
|
||||
return f"Couldn't parse a response. Raw output:\n{result.text}"
|
||||
lines = [f"- [{t.get('owner') or '?'}] {t['action']}" for t in result.parsed["tasks"]]
|
||||
return "\n".join(lines) or "(no tasks found)"
|
||||
```
|
||||
|
||||
A third worked example, this time with image input, lives in the
|
||||
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example)
|
||||
repo (companion repo for reference plugins — not bundled with
|
||||
hermes-agent itself). For the async surface (`acomplete()` /
|
||||
`acomplete_structured()` with `asyncio.gather()`), see
|
||||
[`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example)
|
||||
in the same repo.
|
||||
|
||||
## When to use which
|
||||
|
||||
| You want… | Reach for |
|
||||
|---|---|
|
||||
| A free-form text response (translation, summary, rewrite, generation) | `complete()` |
|
||||
| A multi-turn prompt (system + few-shot examples + user) | `complete()` |
|
||||
| A typed dict back, validated against a schema | `complete_structured()` |
|
||||
| Image-or-text input with a typed dict back | `complete_structured()` |
|
||||
| The same call from async code (gateway adapters, async hooks) | `acomplete()` / `acomplete_structured()` |
|
||||
|
||||
Everything else — provider selection, model resolution, auth, fallback,
|
||||
timeout, vision routing — is the same across all four.
|
||||
|
||||
## API surface
|
||||
|
||||
`ctx.llm` is an instance of `agent.plugin_llm.PluginLlm`.
|
||||
|
||||
### `complete()`
|
||||
|
||||
```python
|
||||
result = ctx.llm.complete(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
provider=None, # optional, gated — Hermes provider id (e.g. "openrouter")
|
||||
model=None, # optional, gated — whatever string that provider expects
|
||||
temperature=None,
|
||||
max_tokens=None,
|
||||
timeout=None, # seconds
|
||||
agent_id=None, # optional, gated
|
||||
profile=None, # optional, gated — explicit auth-profile name
|
||||
purpose="optional-audit-string",
|
||||
)
|
||||
# → PluginLlmCompleteResult(text, provider, model, agent_id, usage, audit)
|
||||
```
|
||||
|
||||
Plain chat completion. `messages` is the standard OpenAI shape — a
|
||||
list of `{"role": "...", "content": "..."}` dicts. Multi-turn
|
||||
prompts (system + few-shot user/assistant pairs + final user) work
|
||||
exactly as they would with the OpenAI SDK.
|
||||
|
||||
`provider=` and `model=` are independent and follow the same shape
|
||||
as the host's main config (`model.provider` + `model.model`). Set
|
||||
just `model=` to use the user's active provider with a different
|
||||
model on it. Set both to switch providers entirely. Either argument
|
||||
without operator opt-in raises `PluginLlmTrustError`.
|
||||
|
||||
### `complete_structured()`
|
||||
|
||||
```python
|
||||
result = ctx.llm.complete_structured(
|
||||
instructions="What you want extracted.",
|
||||
input=[
|
||||
{"type": "text", "text": "..."},
|
||||
{"type": "image", "data": b"...", "mime_type": "image/png"},
|
||||
{"type": "image", "url": "https://..."},
|
||||
],
|
||||
json_schema={...}, # optional — triggers parsed result + validation
|
||||
json_mode=False, # set True without a schema to ask for JSON anyway
|
||||
schema_name=None, # optional human-readable schema name
|
||||
system_prompt=None,
|
||||
provider=None, # optional, gated
|
||||
model=None, # optional, gated
|
||||
temperature=None,
|
||||
max_tokens=None,
|
||||
timeout=None,
|
||||
agent_id=None,
|
||||
profile=None,
|
||||
purpose=None,
|
||||
)
|
||||
# → PluginLlmStructuredResult(text, provider, model, agent_id,
|
||||
# usage, parsed, content_type, audit)
|
||||
```
|
||||
|
||||
Inputs are typed text or image blocks (raw bytes get base64 encoded
|
||||
as a `data:` URL automatically). When `json_schema` or
|
||||
`json_mode=True` is supplied, the host requests JSON output via
|
||||
`response_format`, parses it locally as a fallback, and validates
|
||||
against your schema if `jsonschema` is installed.
|
||||
|
||||
* `result.content_type == "json"` — `result.parsed` is a Python
|
||||
object that matches your schema.
|
||||
* `result.content_type == "text"` — parsing or validation failed;
|
||||
inspect `result.text` for the raw model response.
|
||||
|
||||
### Async
|
||||
|
||||
```python
|
||||
result = await ctx.llm.acomplete(messages=...)
|
||||
result = await ctx.llm.acomplete_structured(instructions=..., input=...)
|
||||
```
|
||||
|
||||
Same arguments and result types as their sync counterparts. Use
|
||||
these from gateway adapters, async hooks, or any plugin code
|
||||
already running on an asyncio loop.
|
||||
|
||||
### Result attributes
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PluginLlmCompleteResult:
|
||||
text: str # the assistant's response
|
||||
provider: str # e.g. "openrouter", "anthropic"
|
||||
model: str # whatever the provider returned for this call
|
||||
agent_id: str # whose model/auth was used
|
||||
usage: PluginLlmUsage # tokens + cache + cost estimate
|
||||
audit: Dict[str, Any] # plugin_id, purpose, profile
|
||||
|
||||
@dataclass
|
||||
class PluginLlmStructuredResult(PluginLlmCompleteResult):
|
||||
parsed: Optional[Any] # JSON object when content_type == "json"
|
||||
content_type: str # "json" or "text"
|
||||
# audit also carries schema_name when supplied
|
||||
```
|
||||
|
||||
`usage` carries `input_tokens`, `output_tokens`, `total_tokens`,
|
||||
`cache_read_tokens`, `cache_write_tokens`, and `cost_usd` when the
|
||||
provider returns those fields.
|
||||
|
||||
## Trust gate
|
||||
|
||||
The default behaviour is fail-closed. With no `plugins.entries`
|
||||
config block, a plugin can:
|
||||
|
||||
* run any of the four methods against the user's active provider
|
||||
and model,
|
||||
* set request-shaping arguments (`temperature`, `max_tokens`,
|
||||
`timeout`, `system_prompt`, `purpose`, `messages`, `instructions`,
|
||||
`input`, `json_schema`),
|
||||
|
||||
…and that's it. `provider=`, `model=`, `agent_id=`, and `profile=`
|
||||
arguments raise `PluginLlmTrustError` until the operator opts in.
|
||||
|
||||
**Most plugins never need this section.** A plugin that just calls
|
||||
`ctx.llm.complete(messages=...)` with no overrides runs against
|
||||
whatever the user has active and works zero-config. The block below
|
||||
is only relevant when a plugin specifically wants to pin to a
|
||||
different model or provider than the user.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
entries:
|
||||
my-plugin:
|
||||
llm:
|
||||
# Allow this plugin to choose a different Hermes provider
|
||||
# (must be one Hermes already knows about — same names as
|
||||
# `hermes model` and config.yaml model.provider).
|
||||
allow_provider_override: true
|
||||
|
||||
# Optionally restrict which providers. Use ["*"] for any.
|
||||
allowed_providers:
|
||||
- openrouter
|
||||
- anthropic
|
||||
|
||||
# Allow this plugin to ask for a specific model.
|
||||
allow_model_override: true
|
||||
|
||||
# Optionally restrict which models. Use ["*"] for any.
|
||||
# Models are matched literally against whatever string the
|
||||
# plugin sends — Hermes does not look anything up.
|
||||
allowed_models:
|
||||
- openai/gpt-4o-mini
|
||||
- anthropic/claude-3-5-haiku
|
||||
|
||||
# Allow cross-agent calls (rare).
|
||||
allow_agent_id_override: false
|
||||
|
||||
# Allow the plugin to request a specific stored auth profile
|
||||
# (e.g. a different OAuth account on the same provider).
|
||||
allow_profile_override: false
|
||||
```
|
||||
|
||||
The plugin id is the manifest `name:` field for flat plugins, or the
|
||||
path-derived key for nested plugins (`image_gen/openai`,
|
||||
`memory/honcho`, etc.).
|
||||
|
||||
### What the gate enforces
|
||||
|
||||
| Override | Default | Config key |
|
||||
| --------------- | ------- | -------------------------------- |
|
||||
| `provider=` | denied | `allow_provider_override: true` |
|
||||
| ↳ allowlist | — | `allowed_providers: [...]` |
|
||||
| `model=` | denied | `allow_model_override: true` |
|
||||
| ↳ allowlist | — | `allowed_models: [...]` |
|
||||
| `agent_id=` | denied | `allow_agent_id_override: true` |
|
||||
| `profile=` | denied | `allow_profile_override: true` |
|
||||
|
||||
Each override is independently gated. Granting `allow_model_override`
|
||||
does **not** also grant `allow_provider_override` — a plugin trusted
|
||||
to pick a model is still pinned to the user's active provider unless
|
||||
it gets the provider gate as well.
|
||||
|
||||
### What the gate does NOT need to enforce
|
||||
|
||||
* Request-shaping arguments — `temperature`, `max_tokens`,
|
||||
`timeout`, `system_prompt`, `purpose`, `messages`, `instructions`,
|
||||
`input`, `json_schema`, `schema_name`, `json_mode` — are always
|
||||
allowed; they don't pick credentials or routes.
|
||||
* The default deny posture means an unconfigured plugin can still do
|
||||
useful work — it just runs against the active provider and model.
|
||||
Operators only need to think about `plugins.entries` for plugins
|
||||
that want finer routing.
|
||||
|
||||
## What the host owns
|
||||
|
||||
A complete list of the things `ctx.llm` does for the plugin so you
|
||||
don't have to:
|
||||
|
||||
* **Provider resolution.** Reads `model.provider` + `model.model`
|
||||
from the user's config (or the explicit overrides when trusted).
|
||||
* **Auth.** Pulls API keys, OAuth tokens, or refresh tokens from
|
||||
`~/.hermes/auth.json` / env, including the credential pool when
|
||||
one is configured. The plugin never sees them.
|
||||
* **Vision routing.** When image input is supplied and the user's
|
||||
active text model is text-only, the host falls back to the
|
||||
configured vision model automatically.
|
||||
* **Fallback chain.** If the user's primary provider 5xxs or 429s,
|
||||
the request goes through Hermes' usual aggregator-aware fallback
|
||||
before it returns an error to the plugin.
|
||||
* **Timeout.** Honours your `timeout=` argument, falling back to
|
||||
`auxiliary.<task>.timeout` config or the global aux default.
|
||||
* **JSON shaping.** Sends `response_format` to the provider when
|
||||
you ask for JSON, then re-parses locally from a code-fenced
|
||||
response if the provider returned one.
|
||||
* **Schema validation.** Validates against your `json_schema` when
|
||||
`jsonschema` is installed; logs a debug line and skips strict
|
||||
validation otherwise.
|
||||
* **Audit log.** Each call writes one INFO line to `agent.log` with
|
||||
the plugin id, provider/model, purpose, and token totals.
|
||||
|
||||
## What the plugin owns
|
||||
|
||||
* **Request shape.** `messages` for chat, `instructions` + `input`
|
||||
for structured. The plugin builds the prompt; the host runs it.
|
||||
* **Schema.** Whatever shape you want back. The host doesn't infer
|
||||
it for you.
|
||||
* **Error handling.** `complete_structured()` raises `ValueError` on
|
||||
empty inputs and on schema-validation failure. `PluginLlmTrustError`
|
||||
fires when the trust gate denies an override. Anything else
|
||||
(provider 5xx, no credentials configured, timeout) raises whatever
|
||||
`auxiliary_client.call_llm()` raises.
|
||||
* **Cost.** Every call runs against the user's paid provider. Don't
|
||||
loop on `complete()` for every gateway message without thinking
|
||||
about token spend.
|
||||
|
||||
## Where this fits in the plugin surface
|
||||
|
||||
Existing `ctx.*` methods extend an existing Hermes subsystem:
|
||||
|
||||
| `ctx.register_tool` | adds a tool the agent can call |
|
||||
| `ctx.register_platform` | wires a new gateway adapter |
|
||||
| `ctx.register_image_gen_provider` | replaces an image-gen backend |
|
||||
| `ctx.register_memory_provider` | replaces the memory backend |
|
||||
| `ctx.register_context_engine` | replaces the context compressor |
|
||||
| `ctx.register_hook` | observes a lifecycle event |
|
||||
|
||||
`ctx.llm` is the first surface that lets a plugin run the same
|
||||
model the user is talking to, *out of band*, without any of the
|
||||
above. That's its only job. If your plugin needs to register a
|
||||
tool the agent invokes, use `register_tool`. If it needs to react
|
||||
to a lifecycle event, use `register_hook`. If it needs to make its
|
||||
own model call — for any reason, structured or not — `ctx.llm`.
|
||||
|
||||
## Reference
|
||||
|
||||
* Implementation: [`agent/plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/agent/plugin_llm.py)
|
||||
* Tests: [`tests/agent/test_plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/tests/agent/test_plugin_llm.py)
|
||||
* Reference plugins (companion repo):
|
||||
* [`plugin-llm-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example) — sync structured extraction with image input
|
||||
* [`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example) — async with `asyncio.gather()`
|
||||
* Auxiliary client (the engine under the hood): see
|
||||
[Provider Runtime](/docs/developer-guide/provider-runtime).
|
||||
@@ -645,6 +645,28 @@ services.hermes-agent.extraPythonPackages = [
|
||||
|
||||
The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start.
|
||||
|
||||
### Optional Dependency Groups (`extraDependencyGroups`)
|
||||
|
||||
For optional extras already declared in hermes-agent's `pyproject.toml` (e.g., memory providers like `hindsight` or `honcho`), use `extraDependencyGroups` to include them in the sealed venv at build time:
|
||||
|
||||
```nix
|
||||
services.hermes-agent = {
|
||||
extraDependencyGroups = [ "hindsight" ];
|
||||
settings.memory.provider = "hindsight";
|
||||
};
|
||||
```
|
||||
|
||||
This is resolved by uv alongside core dependencies in a single pass — no PYTHONPATH patching, no collision risk. Available groups match the `[project.optional-dependencies]` keys in `pyproject.toml` (e.g., `"hindsight"`, `"honcho"`, `"voice"`, `"matrix"`, `"mistral"`, `"bedrock"`).
|
||||
|
||||
**When to use which:**
|
||||
|
||||
| Need | Option |
|
||||
|------|--------|
|
||||
| Enable a pyproject.toml optional extra | `extraDependencyGroups` |
|
||||
| Add an external Python plugin not in pyproject.toml | `extraPythonPackages` |
|
||||
| Add a system binary (pandoc, jq, etc.) | `extraPackages` |
|
||||
| Add a directory-based plugin source tree | `extraPlugins` |
|
||||
|
||||
### Combining Both
|
||||
|
||||
A directory plugin with third-party Python dependencies needs both options:
|
||||
@@ -666,7 +688,9 @@ External flakes can override the package directly:
|
||||
inputs.hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
outputs = { hermes-agent, nixpkgs, ... }: {
|
||||
nixpkgs.overlays = [ hermes-agent.overlays.default ];
|
||||
# Then: pkgs.hermes-agent.override { extraPythonPackages = [...]; }
|
||||
# Then:
|
||||
# pkgs.hermes-agent.override { extraPythonPackages = [...]; }
|
||||
# pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; }
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -812,6 +836,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
|
||||
| `extraPackages` | `listOf package` | `[]` | Extra packages available to the agent. Added to the hermes user's per-user profile so terminal commands, skills, and cron jobs all see them |
|
||||
| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` |
|
||||
| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` |
|
||||
| `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions |
|
||||
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
|
||||
| `restartSec` | `int` | `5` | systemd `RestartSec=` value |
|
||||
|
||||
|
||||
@@ -443,6 +443,28 @@ Only used when the [`teams_pipeline` plugin](/docs/user-guide/messaging/msgraph-
|
||||
| `TEAMS_CHANNEL_ID` | Target channel ID (paired with `TEAMS_TEAM_ID`). |
|
||||
| `TEAMS_CHAT_ID` | Target 1:1 or group chat ID (alternative to team+channel for `graph` mode). |
|
||||
|
||||
### LINE Messaging API
|
||||
|
||||
Used by the bundled LINE platform plugin (`plugins/platforms/line/`). See [Messaging Gateway → LINE](/docs/user-guide/messaging/line) for full setup.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `LINE_CHANNEL_ACCESS_TOKEN` | Long-lived channel access token from the LINE Developers Console (Messaging API tab). Required. |
|
||||
| `LINE_CHANNEL_SECRET` | Channel secret (Basic settings tab); used for HMAC-SHA256 webhook signature verification. Required. |
|
||||
| `LINE_HOST` | Webhook bind host (default: `0.0.0.0`). |
|
||||
| `LINE_PORT` | Webhook bind port (default: `8646`). |
|
||||
| `LINE_PUBLIC_URL` | Public HTTPS base URL (e.g. `https://my-tunnel.example.com`). Required for image / audio / video sends — LINE only accepts HTTPS-reachable URLs. |
|
||||
| `LINE_ALLOWED_USERS` | Comma-separated user IDs allowed to DM the bot (`U`-prefixed). |
|
||||
| `LINE_ALLOWED_GROUPS` | Comma-separated group IDs the bot will respond in (`C`-prefixed). |
|
||||
| `LINE_ALLOWED_ROOMS` | Comma-separated room IDs the bot will respond in (`R`-prefixed). |
|
||||
| `LINE_ALLOW_ALL_USERS` | Dev-only escape hatch — accepts any source. Default: `false`. |
|
||||
| `LINE_HOME_CHANNEL` | Default delivery target for cron jobs with `deliver: line`. |
|
||||
| `LINE_SLOW_RESPONSE_THRESHOLD` | Seconds before the slow-LLM Template Buttons postback fires (default: `45`). Set `0` to disable and always Push-fallback. |
|
||||
| `LINE_PENDING_TEXT` | Bubble text shown alongside the postback button. |
|
||||
| `LINE_BUTTON_LABEL` | Postback button label (default: `Get answer`). |
|
||||
| `LINE_DELIVERED_TEXT` | Reply when an already-delivered postback is tapped again (default: `Already replied ✅`). |
|
||||
| `LINE_INTERRUPTED_TEXT` | Reply when a `/stop`-orphaned postback button is tapped (default: `Run was interrupted before completion.`). |
|
||||
|
||||
### Advanced Messaging Tuning
|
||||
|
||||
Advanced per-platform knobs for throttling the outbound message batcher. Most users never need to touch these; defaults are set to respect each platform's rate limits without feeling sluggish.
|
||||
|
||||
@@ -65,7 +65,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator) | Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban wor... | `devops/kanban-orchestrator` |
|
||||
| [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator) | Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill... | `devops/kanban-orchestrator` |
|
||||
| [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker) | Pitfalls, examples, and edge cases for Hermes Kanban workers. The lifecycle itself is auto-injected into every worker's system prompt as KANBAN_GUIDANCE (from agent/prompt_builder.py); this skill is what you load when you want deeper det... | `devops/kanban-worker` |
|
||||
| [`webhook-subscriptions`](/docs/user-guide/skills/bundled/devops/devops-webhook-subscriptions) | Webhook subscriptions: event-driven agent runs. | `devops/webhook-subscriptions` |
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
|
||||
| `/background <prompt>` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). |
|
||||
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) |
|
||||
| `/handoff <platform>` | **CLI only.** Hand the current session off to a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Matrix). The gateway picks it up immediately, creates a fresh thread on platforms that support threads (Telegram topics, Discord text-channel threads, Slack message-anchored threads), re-binds the destination to your CLI session_id so the full role-aware transcript replays, and forges a synthetic user turn so the agent confirms it's working in the new place. Your CLI exits cleanly on success with a `/resume` hint; resume locally any time with `/resume <title>`. Refused mid-turn. Requires the gateway to be running and a home channel configured for the target platform (`/sethome` from the destination chat). See [Cross-Platform Handoff](/docs/user-guide/sessions#cross-platform-handoff). |
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -213,7 +214,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
||||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, and `/quit` are **CLI-only** commands.
|
||||
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
|
||||
@@ -423,6 +423,15 @@ Check the browser console for any JavaScript errors
|
||||
|
||||
Use `clear=True` to clear the console after reading, so subsequent calls only show new messages.
|
||||
|
||||
`browser_console` also evaluates JavaScript when called with an `expression` argument — same shape as DevTools console, the result comes back parsed (JSON-serialized objects become dicts; primitive values stay primitive).
|
||||
|
||||
```
|
||||
browser_console(expression="document.querySelector('h1').textContent")
|
||||
browser_console(expression="JSON.stringify(performance.timing)")
|
||||
```
|
||||
|
||||
When a CDP supervisor is active for the current session (typical for any session that's run `browser_navigate` against a CDP-capable backend), evaluation runs over the supervisor's persistent WebSocket — no subprocess startup cost. Falls through to the standard agent-browser CLI path otherwise. Behaviour is identical either way; only latency changes.
|
||||
|
||||
### `browser_cdp`
|
||||
|
||||
Raw Chrome DevTools Protocol passthrough — the escape hatch for browser operations not covered by the other tools. Use for native dialog handling, iframe-scoped evaluation, cookie/network control, or any CDP verb the agent needs.
|
||||
|
||||
@@ -64,8 +64,6 @@ The repo ships these bundled plugins under `plugins/`. All are opt-in — enable
|
||||
| `image_gen/xai` | image backend | xAI `grok-2-image` backend |
|
||||
| `hermes-achievements` | dashboard tab | Steam-style collectible badges generated from your real Hermes session history |
|
||||
| `kanban/dashboard` | dashboard tab | Kanban board UI for the multi-agent dispatcher — tasks, comments, fan-out, board switching. See [Kanban Multi-Agent](./kanban.md). |
|
||||
| `example-dashboard` | dashboard example | Reference dashboard plugin for [Extending the Dashboard](./extending-the-dashboard.md) |
|
||||
| `strike-freedom-cockpit` | dashboard skin | Sample custom dashboard skin |
|
||||
|
||||
Memory providers (`plugins/memory/*`) and context engines (`plugins/context_engine/*`) are listed separately on [Memory Providers](./memory-providers.md) — they're managed through `hermes memory` and `hermes plugins` respectively. The full per-plugin detail for the two long-running hooks-based plugins follows.
|
||||
|
||||
|
||||
@@ -681,7 +681,7 @@ Key points:
|
||||
- Multiple plugins can claim the same page-scoped slot. They render stacked in registration order.
|
||||
- Zero footprint when no plugin registers: the built-in page renders exactly as before.
|
||||
|
||||
The bundled `example-dashboard` plugin ships a live demo that injects a banner into `sessions:top` — install it to see the pattern end-to-end.
|
||||
A reference plugin (`example-dashboard` in [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins/tree/main/example-dashboard)) ships a live demo that injects a banner into `sessions:top` — install it to see the pattern end-to-end.
|
||||
|
||||
### Slot-only plugins (`tab.hidden`)
|
||||
|
||||
@@ -818,7 +818,7 @@ If a plugin's script fails to load (404, syntax error, exception during IIFE), t
|
||||
|
||||
## Combined theme + plugin demo
|
||||
|
||||
The repo ships `plugins/strike-freedom-cockpit/` as a complete reskin demo. It pairs a theme YAML with a slot-only plugin to produce a cockpit-style HUD without forking the dashboard.
|
||||
The [`strike-freedom-cockpit`](https://github.com/NousResearch/hermes-example-plugins/tree/main/strike-freedom-cockpit) plugin (companion repo `hermes-example-plugins`) is a complete reskin demo. It pairs a theme YAML with a slot-only plugin to produce a cockpit-style HUD without forking the dashboard.
|
||||
|
||||
**What it demonstrates:**
|
||||
|
||||
@@ -832,17 +832,19 @@ The repo ships `plugins/strike-freedom-cockpit/` as a complete reskin demo. It p
|
||||
**Install:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-example-plugins.git
|
||||
|
||||
# Theme
|
||||
cp plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \
|
||||
cp hermes-example-plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \
|
||||
~/.hermes/dashboard-themes/
|
||||
|
||||
# Plugin
|
||||
cp -r plugins/strike-freedom-cockpit ~/.hermes/plugins/
|
||||
cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/
|
||||
```
|
||||
|
||||
Open the dashboard, pick **Strike Freedom** from the theme switcher. The cockpit sidebar appears, the crest shows in the header, the tagline replaces the footer. Switch back to **Hermes Teal** and the plugin remains installed but invisible (the `sidebar` slot only renders under the `cockpit` layout variant).
|
||||
|
||||
Read the plugin source (`plugins/strike-freedom-cockpit/dashboard/dist/index.js`) to see how it reads CSS vars, guards against older dashboards without slot support, and registers three slots from one bundle.
|
||||
Read the plugin source (`strike-freedom-cockpit/dashboard/dist/index.js` in the companion repo) to see how it reads CSS vars, guards against older dashboards without slot support, and registers three slots from one bundle.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ hermes dashboard # opens http://127.0.0.1:9119 in your browser
|
||||
# click Kanban in the left nav
|
||||
```
|
||||
|
||||
The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from.
|
||||
The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from.
|
||||
|
||||
This tutorial uses the `default` board throughout. If you want multiple isolated queues (one per project / repo / domain), see [Boards (multi-project)](./kanban#boards-multi-project) in the overview — the same CLI / dashboard / worker flows apply per board, and workers physically cannot see tasks on other boards.
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Kanban worker lanes
|
||||
|
||||
A **worker lane** is a class of process that the kanban dispatcher can route tasks to. Each lane has an identity (the assignee string), a spawn mechanism, and a contract for what it must do with the task once spawned.
|
||||
|
||||
This page is the contract. It exists for two audiences:
|
||||
|
||||
- **Operators** picking which lanes to wire into a board (which profiles to create, which assignees to use).
|
||||
- **Plugin / integration authors** wanting to add a new lane shape (a CLI worker that wraps Codex / Claude Code / OpenCode, a containerised review worker, a non-Hermes service that pulls tasks via the API).
|
||||
|
||||
If you're writing the worker code itself — the agent that runs *inside* a lane — the [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill is the deeper procedural detail.
|
||||
|
||||
## The hierarchy
|
||||
|
||||
```text
|
||||
Hermes Kanban = canonical task lifecycle + audit trail
|
||||
Worker lane = implementation executor for one assigned card
|
||||
Reviewer = human or human-proxy that gates "done"
|
||||
GitHub PR = upstreamable artifact (optional, for code lanes)
|
||||
```
|
||||
|
||||
Hermes Kanban owns lifecycle truth — `ready` → `running` → `blocked` / `done` / `archived`. Worker lanes execute work but never own that truth; everything they do flows back through the kanban kernel via the `kanban_*` tools (or, for non-Hermes external workers, via the API). Reviewers gate the transition from "code change written" to "task done."
|
||||
|
||||
## What a lane provides
|
||||
|
||||
To be a kanban worker lane, an integration must provide three things:
|
||||
|
||||
### 1. An assignee string
|
||||
|
||||
The dispatcher matches `task.assignee` against either a Hermes profile name (the default lane shape) or a registered non-spawnable identifier (the plugin lane shape — see [Adding an external CLI worker lane](#adding-an-external-cli-worker-lane) below). Tasks whose assignee doesn't resolve are left on `ready` with a `skipped_nonspawnable` event so a board operator can fix them; they are not silently dropped or executed by an arbitrary fallback.
|
||||
|
||||
### 2. A spawn mechanism
|
||||
|
||||
For Hermes profile lanes, the dispatcher's `_default_spawn` runs `hermes -p <assignee> chat -q <prompt>` (or the equivalent module form when the `hermes` shim isn't on `$PATH`) inside the task's pinned workspace, with these env vars set:
|
||||
|
||||
| Variable | Carries |
|
||||
|---|---|
|
||||
| `HERMES_KANBAN_TASK` | the task id the worker is operating on |
|
||||
| `HERMES_KANBAN_DB` | absolute path to the per-board SQLite file |
|
||||
| `HERMES_KANBAN_BOARD` | board slug |
|
||||
| `HERMES_KANBAN_WORKSPACES_ROOT` | root of the board's workspace tree |
|
||||
| `HERMES_KANBAN_WORKSPACE` | absolute path to *this* task's workspace |
|
||||
| `HERMES_KANBAN_RUN_ID` | the current run's id (for the lifecycle gate) |
|
||||
| `HERMES_KANBAN_CLAIM_LOCK` | the claim lock string (`<host>:<pid>:<uuid>`) |
|
||||
| `HERMES_PROFILE` | the worker's own profile name (for `kanban_comment` author attribution) |
|
||||
| `HERMES_TENANT` | tenant namespace, if the task has one |
|
||||
|
||||
For non-Hermes lanes (registered via a plugin), the plugin supplies its own `spawn_fn` callable that gets `task`, `workspace`, and `board` and returns an optional pid for crash detection.
|
||||
|
||||
### 3. A lifecycle terminator
|
||||
|
||||
Every claim must end in exactly one of:
|
||||
|
||||
- `kanban_complete(summary=..., metadata=...)` — task succeeds, status flips to `done`.
|
||||
- `kanban_block(reason=...)` — task waits for human input, status flips to `blocked`. The dispatcher respawns when `kanban_unblock` runs.
|
||||
- The worker process exits without a tool call. The kernel reaps it and emits `crashed` (PID died) or `gave_up` (consecutive-failure breaker tripped) or `timed_out` (max_runtime exceeded). This is the failure path; healthy workers don't end here.
|
||||
|
||||
The kanban kernel enforces that exactly one of these terminates each run. A worker that calls neither and exits normally is treated as crashed.
|
||||
|
||||
## Outputs and the review-required convention
|
||||
|
||||
For most code-changing tasks, the work isn't truly *done* the moment the worker finishes — it needs a human reviewer. The kanban kernel doesn't enforce this distinction (a "code-changing task" is fuzzy and forcing block-instead-of-complete on every code worker would break flows where no review is wanted). It's a convention layered on top:
|
||||
|
||||
- **Block instead of complete**, with `reason` prefixed `review-required: ` so the dashboard / `hermes kanban show` surfaces the row as awaiting review.
|
||||
- **Drop structured metadata into a `kanban_comment` first** since `kanban_block` only carries the human-readable `reason`. Comments are the durable annotation channel — every audit-relevant field (changed_files, tests_run, diff_path or PR url, decisions) belongs there.
|
||||
- **Reviewer either approves and unblocks**, which respawns the worker with the comment thread for follow-ups; or asks for changes via another comment, which the next worker run sees as part of `kanban_show`'s context.
|
||||
|
||||
The [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill has worked examples for both `kanban_complete` (truly terminal tasks — typo fixes, docs changes, research writeups) and the `review-required` block pattern.
|
||||
|
||||
## Logs and audit trail
|
||||
|
||||
The dispatcher writes per-task worker stdout/stderr to `<board-root>/logs/<task_id>.log`. Logs are auditable from kanban metadata:
|
||||
|
||||
- `task_runs` rows carry the `log_path`, exit code (where available), summary, and metadata.
|
||||
- `task_events` rows carry every state transition (`promoted`, `claimed`, `heartbeat`, `completed`, `blocked`, `gave_up`, `crashed`, `timed_out`, `reclaimed`, `claim_extended`).
|
||||
- `kanban_show` returns both, so a reviewer (or a follow-up worker) reading the task gets the full history without needing dashboard access.
|
||||
|
||||
The dashboard renders run history with summaries, metadata blocks, and exit-status badges. CLI users can run `hermes kanban tail <task_id>` to follow live, or `hermes kanban runs <task_id>` for the historical attempt list.
|
||||
|
||||
## Existing lane shapes
|
||||
|
||||
### Hermes profile lane (default)
|
||||
|
||||
The shape every kanban worker takes today: the assignee is a profile name, the dispatcher spawns `hermes -p <profile>`, the worker auto-loads the [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill plus the `KANBAN_GUIDANCE` system-prompt block, and uses the `kanban_*` tools to terminate the run. No setup beyond defining the profile.
|
||||
|
||||
When you create profiles for your fleet, choose names that match the *role* you want the orchestrator to route to. The orchestrator (when there is one) discovers your profile names via `hermes profile list` — there's no fixed roster the system assumes (see the [`kanban-orchestrator`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-orchestrator/SKILL.md) skill for the orchestrator side of the contract).
|
||||
|
||||
### Orchestrator profile lane
|
||||
|
||||
A specialisation of the profile lane: an orchestrator is a Hermes profile whose toolset includes `kanban` but excludes `terminal` / `file` / `code` / `web` for implementation. Its job is decomposing a high-level goal into child tasks via `kanban_create` + `kanban_link` and stepping back. The orchestrator skill encodes the anti-temptation rules.
|
||||
|
||||
## Adding an external CLI worker lane
|
||||
|
||||
Wiring a non-Hermes CLI tool (Codex CLI, Claude Code CLI, OpenCode CLI, a local coding-model runner, etc.) as a kanban worker lane is *not yet a paved path*. The dispatcher's spawn function is pluggable (`spawn_fn` is a parameter on `dispatch_once`), and a plugin could register its own `spawn_fn` for a non-Hermes assignee, but the surrounding integration work — wrapping the CLI's exit code into `kanban_complete` / `kanban_block` calls, mapping the CLI's workspace/sandbox conventions onto the dispatcher's `HERMES_KANBAN_WORKSPACE` env, handling auth and per-CLI policy — is still per-integration design work.
|
||||
|
||||
If you're considering adding a CLI lane, open an issue describing the specific CLI and the workflow you're trying to enable. The contract above is the constraints any such lane must satisfy; the implementation shape (one plugin per CLI vs a generic CLI-runner plugin parameterised by config) is open.
|
||||
|
||||
The historical issue for this is [#19931](https://github.com/NousResearch/hermes-agent/issues/19931) and the closed-not-merged Codex-specific PR [#19924](https://github.com/NousResearch/hermes-agent/pull/19924) — those describe the original architecture proposal but didn't land a runner.
|
||||
|
||||
## Failure modes the dispatcher handles
|
||||
|
||||
So lane authors don't have to reimplement these:
|
||||
|
||||
- **Stale claim TTL** — a worker that claims and then never heartbeats / completes / blocks gets reclaimed after `DEFAULT_CLAIM_TTL_SECONDS` (15 min default) — but only if the worker process has actually died. A live worker (slow model spending 20+ min in one tool-free LLM call) gets the claim *extended* instead of killed; only a dead PID is reclaimed.
|
||||
- **Crashed worker** — a worker whose host-local PID has vanished is detected by `detect_crashed_workers` and reaped; the task increments `consecutive_failures` and may auto-block when the breaker trips.
|
||||
- **Run-level retry** — when a task is retried (post-block, post-crash, post-reclaim), the worker can use the `expected_run_id` parameter on terminating tools to fail fast if its own run was already superseded.
|
||||
- **Per-task max runtime** — `task.max_runtime_seconds` hard-caps wall-clock time per run, regardless of PID liveness. Catches genuinely-deadlocked workers that the live-PID extension would otherwise keep running.
|
||||
- **Stranded-task detection** — a ready task whose assignee never produces a claim within `kanban.stranded_threshold_seconds` (default 30 min) shows up in `hermes kanban diagnostics` as a `stranded_in_ready` warning. Severity escalates to error at 2x the threshold and critical at 6x. Catches typo'd assignees, deleted profiles, and down external worker pools in one signal — identity-agnostic, no per-board allowlist to curate.
|
||||
|
||||
## Related
|
||||
|
||||
- [Kanban overview](./kanban) — the user-facing intro.
|
||||
- [Kanban tutorial](./kanban-tutorial) — walkthrough with the dashboard open.
|
||||
- [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) — the skill the worker process loads.
|
||||
- [`kanban-orchestrator`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-orchestrator/SKILL.md) — the orchestrator side.
|
||||
@@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t
|
||||
|
||||
The board has two front doors, both backed by the same `~/.hermes/kanban.db`:
|
||||
|
||||
- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`. The dispatcher spawns each worker with these tools already in its schema; the model reads its task and hands work off by calling them directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below.
|
||||
- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`. The dispatcher spawns each worker with these tools already in its schema; orchestrator profiles can also enable the `kanban` toolset explicitly. The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below.
|
||||
- **You (and scripts, and cron) drive the board through `hermes kanban …`** on the CLI, `/kanban …` as a slash command, or the dashboard. These are for humans and automation — the places without a tool-calling model behind them.
|
||||
|
||||
Both surfaces route through the same `kanban_db` layer, so reads see a consistent view and writes can't drift. The rest of this page shows CLI examples because they're easy to copy-paste, but every CLI verb has a tool-call equivalent the model uses.
|
||||
@@ -231,17 +231,19 @@ hermes kanban block t_abc "need input" --ids t_def t_hij
|
||||
|
||||
## How workers interact with the board
|
||||
|
||||
**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema — seven tools that read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI.
|
||||
**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema. The same toolset is also available to orchestrator profiles that enable `kanban` in their toolsets config. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI.
|
||||
|
||||
| Tool | Purpose | Required params |
|
||||
|---|---|---|
|
||||
| `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full pre-formatted `worker_context`). Defaults to the env's task id. | — |
|
||||
| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — |
|
||||
| `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` |
|
||||
| `kanban_block` | Escalate for human input with a `reason`. | `reason` |
|
||||
| `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — |
|
||||
| `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` |
|
||||
| `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` |
|
||||
| `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` |
|
||||
| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` |
|
||||
|
||||
A typical worker turn looks like:
|
||||
|
||||
@@ -278,7 +280,7 @@ kanban_create(
|
||||
kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies")
|
||||
```
|
||||
|
||||
The three "(Orchestrators)" tools — `kanban_create`, `kanban_link`, and `kanban_comment` on foreign tasks — are available to every worker; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out and orchestrator profiles don't execute.
|
||||
The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_unblock`, and `kanban_comment` on foreign tasks — are available through the same toolset; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out or route unrelated work, and orchestrator profiles don't execute implementation work. Dispatcher-spawned workers are still task-scoped for destructive lifecycle operations and cannot mutate unrelated tasks.
|
||||
|
||||
### Why tools instead of shelling to `hermes kanban`
|
||||
|
||||
@@ -391,7 +393,7 @@ These skills are **additive** to the built-in `kanban-worker` — the dispatcher
|
||||
|
||||
### The orchestrator skill
|
||||
|
||||
A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this as tool-call patterns: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook keyed on `kanban_create` / `kanban_link` / `kanban_comment`.
|
||||
A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to one of the profiles you've set up, and steps back. The `kanban-orchestrator` skill encodes this as tool-call patterns: anti-temptation rules, a Step-0 profile-discovery prompt (the dispatcher silently fails on unknown assignee names, so the orchestrator must ground every card in profiles that actually exist on your machine), and a decomposition playbook keyed on `kanban_create` / `kanban_link` / `kanban_comment`.
|
||||
|
||||
A canonical orchestrator turn (two parallel researchers handing off to a writer):
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function.
|
||||
| Register an image-generation backend | `ctx.register_image_gen_provider(provider)` — see [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) |
|
||||
| Register a context-compression engine | `ctx.register_context_engine(engine)` — see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) |
|
||||
| Register a memory backend | Subclass `MemoryProvider` in `plugins/memory/<name>/__init__.py` — see [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) (uses a separate discovery system) |
|
||||
| Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/docs/developer-guide/plugin-llm-access) |
|
||||
| Register an inference backend (LLM provider) | `register_provider(ProviderProfile(...))` in `plugins/model-providers/<name>/__init__.py` — see [Model Provider Plugins](/docs/developer-guide/model-provider-plugin) (uses a separate discovery system) |
|
||||
|
||||
## Plugin discovery
|
||||
|
||||
@@ -32,6 +32,44 @@ If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription,
|
||||
|
||||
---
|
||||
|
||||
## How `web_extract` handles long pages
|
||||
|
||||
Backends return raw page markdown, which can be huge (forum threads, docs sites, news articles with embedded comments). To keep your context window usable and your costs down, `web_extract` runs returned content through the **`web_extract` auxiliary model** before handing it to the agent. Behavior is purely size-driven:
|
||||
|
||||
| Page size (characters) | What happens |
|
||||
|------------------------|--------------|
|
||||
| Under 5 000 | Returned as-is — no LLM call, full markdown reaches the agent |
|
||||
| 5 000 – 500 000 | Single-pass summary via the `web_extract` auxiliary model, capped at ~5 000 chars of output |
|
||||
| 500 000 – 2 000 000 | Chunked: split into 100 k-char chunks, summarize each in parallel, then synthesize a final summary (~5 000 chars) |
|
||||
| Over 2 000 000 | Refused with a hint to use `web_crawl` with focused extraction instructions or a more specific source |
|
||||
|
||||
The summary keeps quotes, code blocks, and key facts in their original formatting — it's a content compressor, not a paraphraser. If summarization fails or times out, Hermes falls back to the first ~5 000 chars of raw content rather than a useless error.
|
||||
|
||||
### Which model does the summarizing?
|
||||
|
||||
The `web_extract` auxiliary task. By default (`auxiliary.web_extract.provider: "auto"`), this is your **main chat model** — same provider, same model as `hermes model`. That's fine for most setups, but on expensive reasoning models (Opus, MiniMax M2.7, etc.) every long-page extract adds meaningful cost.
|
||||
|
||||
To route extraction summaries to a cheap, fast model regardless of your main:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
auxiliary:
|
||||
web_extract:
|
||||
provider: openrouter
|
||||
model: google/gemini-3-flash-preview
|
||||
timeout: 360 # seconds; raise if you hit summarization timeouts
|
||||
```
|
||||
|
||||
Or pick interactively: `hermes model` → **Configure auxiliary models** → `web_extract`.
|
||||
|
||||
See [Auxiliary Models](/docs/user-guide/configuration#auxiliary-models) for the full reference and per-task override patterns.
|
||||
|
||||
### When summarization gets in the way
|
||||
|
||||
If you specifically need raw, unsummarized page content — for example, you're scraping a structured page where the LLM summary would drop important fields — use `browser_navigate` + `browser_snapshot` instead. The browser tool returns the live accessibility tree without auxiliary-model rewriting (subject to its own 8 000-char snapshot cap on huge pages).
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### Quick setup via `hermes tools`
|
||||
@@ -329,6 +367,14 @@ Some public instances disable certain search engines or categories. Try:
|
||||
|
||||
Switch to a self-hosted instance (see [Option A](#option-a--self-host-with-docker-recommended) above). With Docker, your own instance has no rate limits.
|
||||
|
||||
### `web_extract` returns truncated content with a "summarization timed out" note
|
||||
|
||||
The auxiliary model didn't finish summarizing within the configured timeout. Either:
|
||||
|
||||
- Raise `auxiliary.web_extract.timeout` in `config.yaml` (default 360s on fresh installs, 30s if the key is missing)
|
||||
- Switch the `web_extract` auxiliary task to a faster model (e.g. `google/gemini-3-flash-preview`) — see [How `web_extract` handles long pages](#how-web_extract-handles-long-pages)
|
||||
- For pages where summarization is the wrong tool, use `browser_navigate` instead
|
||||
|
||||
---
|
||||
|
||||
## Optional skill: `searxng-search`
|
||||
|
||||
@@ -462,6 +462,48 @@ display:
|
||||
tool_progress_command: true
|
||||
```
|
||||
|
||||
## Slash Command Access Control
|
||||
|
||||
By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the Discord platform's `extra` block:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
discord:
|
||||
extra:
|
||||
# Existing user allowlist (unchanged)
|
||||
allow_from:
|
||||
- "123456789012345678" # admin user ID
|
||||
- "999888777666555444" # regular user ID
|
||||
|
||||
# NEW — admins get all slash commands (built-in + plugin)
|
||||
allow_admin_from:
|
||||
- "123456789012345678"
|
||||
|
||||
# NEW — non-admin allowed users can only run these slash commands.
|
||||
# /help and /whoami are always allowed so users can see their access.
|
||||
user_allowed_commands:
|
||||
- status
|
||||
- model
|
||||
- history
|
||||
|
||||
# Optional: separate admin / command lists for server channels
|
||||
group_allow_admin_from:
|
||||
- "123456789012345678"
|
||||
group_user_allowed_commands:
|
||||
- status
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- A user in `allow_admin_from` for a scope (DM or server channel) can run **every** registered slash command — built-in AND plugin-registered — through the live command registry.
|
||||
- A user not in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.
|
||||
- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally; they just can't trigger arbitrary commands.
|
||||
- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes.
|
||||
- DM admin status does not imply server-channel admin status. Each scope has its own admin list.
|
||||
|
||||
Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run.
|
||||
|
||||
## Interactive Model Picker
|
||||
|
||||
Send `/model` with no arguments in a Discord channel to open a dropdown-based model picker:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "Messaging Gateway"
|
||||
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
|
||||
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, LINE, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
|
||||
---
|
||||
|
||||
# Messaging Gateway
|
||||
|
||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||
|
||||
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
||||
|
||||
@@ -34,6 +34,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
|
||||
| QQ | ✅ | ✅ | ✅ | — | — | ✅ | — |
|
||||
| Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||
| Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — |
|
||||
| LINE | — | ✅ | ✅ | — | — | ✅ | — |
|
||||
|
||||
**Voice** = TTS audio replies and/or voice message transcription. **Images** = send/receive images. **Files** = send/receive file attachments. **Threads** = threaded conversations. **Reactions** = emoji reactions on messages. **Typing** = typing indicator while processing. **Streaming** = progressive message updates via editing.
|
||||
|
||||
@@ -133,6 +134,7 @@ hermes gateway status --system # Linux only: inspect the system service
|
||||
| `/retry` | Retry the last message |
|
||||
| `/undo` | Remove the last exchange |
|
||||
| `/status` | Show session info |
|
||||
| `/whoami` | Show your slash command access on this scope (admin / user / unrestricted) |
|
||||
| `/stop` | Stop the running agent |
|
||||
| `/approve` | Approve a pending dangerous command |
|
||||
| `/deny` | Reject a pending dangerous command |
|
||||
@@ -220,6 +222,33 @@ hermes pairing revoke telegram 123456789 # Remove access
|
||||
|
||||
Pairing codes expire after 1 hour, are rate-limited, and use cryptographic randomness.
|
||||
|
||||
### Slash Command Access Control
|
||||
|
||||
Once users are allowed in, you can split them into **admins** (full slash command access) and **regular users** (only the slash commands you explicitly enable). This applies per platform and per scope (DM vs group/channel) and works through the live command registry, so it covers built-in AND plugin-registered slash commands without per-feature wiring.
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
discord:
|
||||
extra:
|
||||
allow_from: ["111", "222", "333"]
|
||||
allow_admin_from: ["111"] # admins → all slash commands
|
||||
user_allowed_commands: [status, model] # what non-admins may run
|
||||
# Optional: separate group/channel scope
|
||||
group_allow_admin_from: ["111"]
|
||||
group_user_allowed_commands: [status]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- A user in `allow_admin_from` for a scope can run **every** registered slash command.
|
||||
- A user in `allow_from` but not in `allow_admin_from` can only run commands in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.
|
||||
- Plain chat is unaffected. Non-admins can still talk to the agent normally; they just can't trigger arbitrary commands.
|
||||
- **Backward compat:** if `allow_admin_from` is not set for a scope, slash gating is disabled for that scope. Existing installs keep working with no changes.
|
||||
- DM admin status does not imply group/channel admin status. Each scope has its own admin list.
|
||||
|
||||
Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/docs/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/docs/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples.
|
||||
|
||||
## Interrupting the Agent
|
||||
|
||||
Send any message while the agent is working to interrupt it. Key behaviors:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
sidebar_position: 17
|
||||
title: "LINE"
|
||||
description: "Set up Hermes Agent as a LINE Messaging API bot"
|
||||
---
|
||||
|
||||
# LINE Setup
|
||||
|
||||
Run Hermes Agent as a [LINE](https://line.me/) bot via the official LINE Messaging API. The adapter lives as a bundled platform plugin under `plugins/platforms/line/` — no core edits, just enable it like any other platform.
|
||||
|
||||
LINE is the dominant messaging app in Japan, Taiwan, and Thailand. If your users live there, this is how they reach you.
|
||||
|
||||
## How the bot responds
|
||||
|
||||
| Context | Behavior |
|
||||
|---------|----------|
|
||||
| **1:1 chat** (`U` IDs) | Responds to every message |
|
||||
| **Group chat** (`C` IDs) | Responds when the group is on the allowlist |
|
||||
| **Multi-user room** (`R` IDs) | Responds when the room is on the allowlist |
|
||||
|
||||
Inbound text, images, audio, video, files, stickers, and locations are all handled. Outbound text uses the **free reply token first** (single-use, ~60s window) and falls back to the metered Push API when the token has expired.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create a LINE Messaging API channel
|
||||
|
||||
1. Go to the [LINE Developers Console](https://developers.line.biz/console/).
|
||||
2. Create a Provider, then under it a **Messaging API** channel.
|
||||
3. From the channel's **Basic settings** tab, copy the **Channel secret**.
|
||||
4. From the **Messaging API** tab, scroll to **Channel access token (long-lived)** and click **Issue**. Copy the token.
|
||||
5. In the **Messaging API** tab, also disable **Auto-reply messages** and **Greeting messages** so they don't fight your bot's replies.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Expose the webhook port
|
||||
|
||||
LINE delivers webhooks over public HTTPS. The default port is `8646` — override with `LINE_PORT` if needed.
|
||||
|
||||
```bash
|
||||
# Cloudflare Tunnel (recommended for production — fixed hostname)
|
||||
cloudflared tunnel --url http://localhost:8646
|
||||
|
||||
# ngrok (good for dev)
|
||||
ngrok http 8646
|
||||
|
||||
# devtunnel
|
||||
devtunnel create hermes-line --allow-anonymous
|
||||
devtunnel port create hermes-line -p 8646 --protocol https
|
||||
devtunnel host hermes-line
|
||||
```
|
||||
|
||||
Copy the `https://...` URL — you'll set it as the webhook URL below. **Leave the tunnel running** while testing. For production, set up a fixed Cloudflare named tunnel so the webhook URL doesn't change on restart.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Configure Hermes
|
||||
|
||||
Add to `~/.hermes/.env`:
|
||||
|
||||
```env
|
||||
LINE_CHANNEL_ACCESS_TOKEN=YOUR_LONG_LIVED_TOKEN
|
||||
LINE_CHANNEL_SECRET=YOUR_CHANNEL_SECRET
|
||||
|
||||
# Allowlist — at least one of these (or LINE_ALLOW_ALL_USERS=true for dev)
|
||||
LINE_ALLOWED_USERS=U1234567890abcdef... # comma-separated U-prefixed IDs
|
||||
LINE_ALLOWED_GROUPS=C1234567890abcdef... # optional group IDs
|
||||
LINE_ALLOWED_ROOMS=R1234567890abcdef... # optional room IDs
|
||||
|
||||
# Required for image / audio / video sends — the public HTTPS base URL
|
||||
# the tunnel resolves to. Without it, send_image/voice/video will refuse.
|
||||
LINE_PUBLIC_URL=https://my-tunnel.example.com
|
||||
```
|
||||
|
||||
Then in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
line:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
That's enough — the bundled-plugin scan in `gateway/config.py` automatically picks up `plugins/platforms/line/`. No `Platform.LINE` enum edit, no `_create_adapter` registration.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Set the webhook URL
|
||||
|
||||
Back in the LINE console:
|
||||
|
||||
1. Open your channel → **Messaging API** tab.
|
||||
2. Under **Webhook settings** → **Webhook URL**, paste `https://<your-tunnel>/line/webhook` (note the `/line/webhook` path — the adapter listens there).
|
||||
3. Click **Verify**. LINE pings the URL; you should see a 200.
|
||||
4. Toggle **Use webhook** to **On**.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run the gateway
|
||||
|
||||
```bash
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
The agent log shows:
|
||||
|
||||
```
|
||||
LINE: webhook listening on 0.0.0.0:8646/line/webhook (public: https://my-tunnel.example.com)
|
||||
```
|
||||
|
||||
Add the bot as a friend from the LINE app (scan the QR in the channel's **Messaging API** tab) and send it a message.
|
||||
|
||||
---
|
||||
|
||||
## Slow LLM responses
|
||||
|
||||
LINE's reply token is single-use and expires roughly 60 seconds after the inbound event. Slow LLMs can't reply in time, which would normally force a paid Push API call.
|
||||
|
||||
When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` seconds (default `45`), the adapter consumes the original reply token to send a **Template Buttons** bubble:
|
||||
|
||||
> 🤔 Still thinking. Tap below to fetch the answer when it's ready.
|
||||
>
|
||||
> [ Get answer ]
|
||||
|
||||
The user taps **Get answer** when convenient — that postback delivers a *fresh* reply token, which the adapter uses to send the cached answer (still free).
|
||||
|
||||
State machine: `PENDING → READY → DELIVERED`, plus `ERROR` for cancelled runs (the orphan PENDING resolves to "Run was interrupted before completion." after `/stop` so the persistent button doesn't loop).
|
||||
|
||||
To disable the postback button and always Push-fallback instead:
|
||||
|
||||
```env
|
||||
LINE_SLOW_RESPONSE_THRESHOLD=0
|
||||
```
|
||||
|
||||
For the postback flow to fire reliably, suppress chatter that would consume the reply token before the threshold:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
display:
|
||||
interim_assistant_messages: false
|
||||
platforms:
|
||||
line:
|
||||
tool_progress: off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron / notification delivery
|
||||
|
||||
```env
|
||||
LINE_HOME_CHANNEL=Uxxxxxxxxxxxxxxxxxxxx # default delivery target
|
||||
```
|
||||
|
||||
Cron jobs with `deliver: line` route to `LINE_HOME_CHANNEL`. The adapter ships a standalone Push-only sender so cron jobs work even when cron runs in a separate process from the gateway.
|
||||
|
||||
---
|
||||
|
||||
## Environment variable reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `LINE_CHANNEL_ACCESS_TOKEN` | yes | — | Long-lived channel access token |
|
||||
| `LINE_CHANNEL_SECRET` | yes | — | Channel secret (HMAC-SHA256 webhook verification) |
|
||||
| `LINE_HOST` | no | `0.0.0.0` | Webhook bind host |
|
||||
| `LINE_PORT` | no | `8646` | Webhook bind port |
|
||||
| `LINE_PUBLIC_URL` | for media | — | Public HTTPS base URL; required for image/voice/video sends |
|
||||
| `LINE_ALLOWED_USERS` | one of | — | Comma-separated user IDs (U-prefixed) |
|
||||
| `LINE_ALLOWED_GROUPS` | one of | — | Comma-separated group IDs (C-prefixed) |
|
||||
| `LINE_ALLOWED_ROOMS` | one of | — | Comma-separated room IDs (R-prefixed) |
|
||||
| `LINE_ALLOW_ALL_USERS` | dev only | `false` | Skip allowlist entirely |
|
||||
| `LINE_HOME_CHANNEL` | no | — | Default cron / notification delivery target |
|
||||
| `LINE_SLOW_RESPONSE_THRESHOLD` | no | `45` | Seconds before the postback button fires (`0` = disabled) |
|
||||
| `LINE_PENDING_TEXT` | no | "🤔 Still thinking…" | Bubble text shown alongside the postback button |
|
||||
| `LINE_BUTTON_LABEL` | no | "Get answer" | Button label |
|
||||
| `LINE_DELIVERED_TEXT` | no | "Already replied ✅" | Reply when an already-delivered button is tapped again |
|
||||
| `LINE_INTERRUPTED_TEXT` | no | "Run was interrupted before completion." | Reply when a `/stop` orphan button is tapped |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"invalid signature" on webhook verify.** The `Channel secret` was copied wrong, or your tunnel rewrote the request body. Verify with `curl -i https://<tunnel>/line/webhook/health` first — that should return `{"status":"ok","platform":"line"}`.
|
||||
|
||||
**Bot receives nothing in groups.** Check `LINE_ALLOWED_GROUPS` includes the `C...` group ID. To find a group ID, send a test message and grep `~/.hermes/logs/gateway.log` for `LINE: rejecting unauthorized source` — the rejected source dict has the IDs.
|
||||
|
||||
**`send_image` fails with "LINE_PUBLIC_URL must be set".** LINE's Messaging API does not accept binary uploads — images, audio, and video must be reachable HTTPS URLs. Set `LINE_PUBLIC_URL` to the tunnel's public hostname and the adapter will serve files from `/line/media/<token>/<filename>` automatically.
|
||||
|
||||
**Postback button never appears.** Either the LLM responded faster than `LINE_SLOW_RESPONSE_THRESHOLD`, or another bubble (tool-progress, streaming) consumed the reply token first. See the suppression block under "Slow LLM responses".
|
||||
|
||||
**"already in use by another profile".** The same channel access token is bound to another running Hermes profile. Stop the other gateway or use a separate channel.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
* **Single bubble per chunk.** Each LINE text bubble is capped at 5000 characters, and at most 5 bubbles are sent per Reply/Push call. Longer responses are truncated with an ellipsis.
|
||||
* **No native message editing.** LINE has no edit-message API — streaming responses always send fresh bubbles, never edit prior ones.
|
||||
* **No Markdown rendering.** Bold (`**`), italics (`*`), code fences, and headings render as literal characters. The adapter strips them before sending; URLs are preserved (`[label](url)` becomes `label (url)`).
|
||||
* **Loading indicator is DM-only.** LINE rejects the chat/loading API for groups and rooms, so the typing indicator only shows in 1:1 chats.
|
||||
@@ -611,7 +611,33 @@ To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and loo
|
||||
|
||||
- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. Hermes uses this for two distinct features: operator-curated [Private Chat Topics](#private-chat-topics-bot-api-94) (config-driven, fixed topic list) and user-driven [Multi-session DM mode](#multi-session-dm-mode-topic) (activated by `/topic`, unlimited user-created topics).
|
||||
- **Privacy policy:** Telegram now requires bots to have a privacy policy. Set one via BotFather with `/setprivacy_policy`, or Telegram may auto-generate a placeholder. This is particularly important if your bot is public-facing.
|
||||
- **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies.
|
||||
- **Bot API 9.5 (Mar 2026): Native streaming via `sendMessageDraft`.** Hermes uses Telegram's native streaming-draft API to render an animated preview of the agent's reply as tokens arrive in private chats. Drops the per-edit jitter you used to see with the legacy `editMessageText` polling path on slow models.
|
||||
|
||||
### Streaming transport (`gateway.streaming.transport`)
|
||||
|
||||
When streaming is enabled (`gateway.streaming.enabled: true`), Hermes picks one of four transports:
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `auto` (default) | Native draft streaming on supported chats (currently Telegram DMs); legacy edit-based path otherwise. Falls back gracefully if a draft frame fails. |
|
||||
| `draft` | Force native drafts. Logs a downgrade and falls back to edit if the chat doesn't support drafts (e.g. groups/topics). |
|
||||
| `edit` | Legacy progressive `editMessageText` polling for every chat type. |
|
||||
| `off` | Disable streaming entirely (final reply only, no progressive updates). |
|
||||
|
||||
In `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
streaming:
|
||||
enabled: true
|
||||
transport: auto # auto | draft | edit | off
|
||||
```
|
||||
|
||||
**What you'll see in DMs with `auto` (default)** — when the agent generates a reply, Telegram shows an animated draft preview that updates token-by-token. When the reply finishes, it's delivered as a regular message and the draft preview clears naturally on the client. Drafts have no message id, so the final answer is what stays in your chat history.
|
||||
|
||||
**What about groups, supergroups, forum topics?** Telegram restricts `sendMessageDraft` to private chats (DMs). The gateway transparently falls back to the edit-based path for everything else — same UX as before.
|
||||
|
||||
**What if a draft frame fails?** Any failure (transient network error, server-side rejection, older python-telegram-bot install) flips that response back to the edit-based path for the rest of the stream. The next response gets a fresh attempt.
|
||||
|
||||
## Rendering: Tables and Link Previews
|
||||
|
||||
@@ -685,6 +711,50 @@ TELEGRAM_GROUP_ALLOWED_USERS="-1001234567890"
|
||||
TELEGRAM_GROUP_ALLOWED_CHATS="-1001234567890"
|
||||
```
|
||||
|
||||
## Slash Command Access Control
|
||||
|
||||
By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the platform's `extra` block:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
# Existing allowlists (unchanged)
|
||||
allow_from:
|
||||
- "123456789" # admin
|
||||
- "555555555" # regular user
|
||||
- "777777777" # regular user
|
||||
|
||||
# NEW — admins get all slash commands (built-in + plugin)
|
||||
allow_admin_from:
|
||||
- "123456789"
|
||||
|
||||
# NEW — non-admin allowed users can only run these slash commands.
|
||||
# /help and /whoami are always allowed so users can see their access.
|
||||
user_allowed_commands:
|
||||
- status
|
||||
- model
|
||||
- history
|
||||
|
||||
# Optional: separate admin/command lists for groups
|
||||
group_allow_admin_from:
|
||||
- "123456789"
|
||||
group_user_allowed_commands:
|
||||
- status
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- A user listed in `allow_admin_from` for a scope (DM or group) can run **every** registered slash command — built-in commands AND plugin-registered ones — through the live registry.
|
||||
- A user in `allow_from` but **not** in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.
|
||||
- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally, they just can't trigger arbitrary commands.
|
||||
- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes.
|
||||
- DM admin status does not imply group admin status. Each scope has its own admin list.
|
||||
- If only `group_allow_admin_from` is set, DM scope stays in unrestricted (backward-compat) mode.
|
||||
|
||||
Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run.
|
||||
|
||||
## Interactive Model Picker
|
||||
|
||||
When you send `/model` with no arguments in a Telegram chat, Hermes shows an interactive inline keyboard for switching models:
|
||||
|
||||
@@ -127,6 +127,44 @@ display:
|
||||
Session IDs follow the format `YYYYMMDD_HHMMSS_<hex>` — CLI/TUI sessions use a 6-char hex suffix (e.g. `20250305_091523_a1b2c3`), gateway sessions use an 8-char suffix (e.g. `20250305_091523_a1b2c3d4`). You can resume by ID (full or unique prefix) or by title — both work with `-c` and `-r`.
|
||||
:::
|
||||
|
||||
## Cross-Platform Handoff
|
||||
|
||||
Use `/handoff <platform>` from a CLI session to transfer the live conversation to a messaging platform's home channel. The agent picks up exactly where the CLI left off — same session id, full role-aware transcript, tool calls and all.
|
||||
|
||||
```bash
|
||||
# Inside a CLI session
|
||||
/handoff telegram
|
||||
```
|
||||
|
||||
What happens:
|
||||
|
||||
1. The CLI validates that `<platform>` is enabled and has a home channel set (run `/sethome` from the destination chat once to configure it).
|
||||
2. The CLI marks the session pending and **block-polls the gateway**. It refuses if the agent is mid-turn — wait for the current response to finish first.
|
||||
3. The gateway watcher claims the handoff and asks the destination adapter for a fresh thread:
|
||||
- **Telegram** — opens a new forum topic (DM topics if Bot API 9.4+ Topics mode is enabled in the chat, or a forum supergroup topic).
|
||||
- **Discord** — creates a 1440-min auto-archive thread under the home text channel.
|
||||
- **Slack** — posts a seed message and uses its `ts` as the thread anchor.
|
||||
- **WhatsApp / Signal / Matrix / SMS** — no native threads, falls back to the home channel directly.
|
||||
4. The gateway re-binds the destination key to your existing CLI session id, then forges a synthetic user turn asking the agent to confirm and summarize. The reply lands in the new thread.
|
||||
5. When the gateway acknowledges success, the CLI prints a `/resume` hint and exits cleanly:
|
||||
|
||||
```
|
||||
↻ Handoff complete. The session is now active on telegram.
|
||||
Resume it on this CLI later with: /resume my-session-title
|
||||
```
|
||||
|
||||
6. From that point, the conversation lives on the platform. Reply in the new thread — anyone authorized in that channel shares the same session, and any later real user message in the thread joins seamlessly because thread sessions key without `user_id`.
|
||||
|
||||
**Resume back to CLI:** when you want to come back to a desktop, just run `/resume <title>` (or `hermes -r "<title>"` from the shell) and pick up where the platform left off.
|
||||
|
||||
**Failure modes:**
|
||||
- No home channel configured → CLI refuses with a `/sethome` hint.
|
||||
- Platform not enabled / gateway not running → CLI times out at 60s with a clear message and your CLI session stays intact.
|
||||
- Thread creation fails (permissions, topics-mode off) → falls back to the home channel directly and still completes; no thread isolation but the handoff itself works.
|
||||
- `adapter.send` fails (rate limit, transient API error) → handoff marked failed with the reason; the row clears so you can retry.
|
||||
|
||||
**Limitation worth knowing:** for non-thread-capable platforms with multi-user group home channels, the synthetic turn keys as a DM-style session. This works for self-DM home channels (the typical setup) but isn't ideal for genuinely shared group chats. Threading covers Telegram / Discord / Slack — by far the common case — so most setups never hit this.
|
||||
|
||||
## Session Naming
|
||||
|
||||
Give sessions human-readable titles so you can find and resume them easily.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: "Kanban Orchestrator"
|
||||
sidebar_label: "Kanban Orchestrator"
|
||||
description: "Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban"
|
||||
description: "Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban"
|
||||
---
|
||||
|
||||
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
|
||||
|
||||
# Kanban Orchestrator
|
||||
|
||||
Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role.
|
||||
Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role.
|
||||
|
||||
## Skill metadata
|
||||
|
||||
@@ -16,7 +16,7 @@ Decomposition playbook + specialist-roster conventions + anti-temptation rules f
|
||||
|---|---|
|
||||
| Source | Bundled (installed by default) |
|
||||
| Path | `skills/devops/kanban-orchestrator` |
|
||||
| Version | `2.0.0` |
|
||||
| Version | `3.0.0` |
|
||||
| Platforms | linux, macos, windows |
|
||||
| Tags | `kanban`, `multi-agent`, `orchestration`, `routing` |
|
||||
| Related skills | [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker) |
|
||||
@@ -31,6 +31,22 @@ The following is the complete skill definition that Hermes loads when this skill
|
||||
|
||||
> The **core worker lifecycle** (including the `kanban_create` fan-out pattern and the "decompose, don't execute" rule) is auto-injected into every kanban process via the `KANBAN_GUIDANCE` system-prompt block. This skill is the deeper playbook when you're an orchestrator profile whose whole job is routing.
|
||||
|
||||
## Profiles are user-configured — not a fixed roster
|
||||
|
||||
Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine.
|
||||
|
||||
Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever.
|
||||
|
||||
**Step 0: discover available profiles before planning.**
|
||||
|
||||
Use one of these:
|
||||
|
||||
- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user.
|
||||
- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering.
|
||||
- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist.
|
||||
|
||||
Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call.
|
||||
|
||||
## When to use the board (vs. just doing the work)
|
||||
|
||||
Create Kanban tasks when any of these are true:
|
||||
@@ -50,24 +66,11 @@ Your job description says "route, don't execute." The rules that enforce that:
|
||||
|
||||
- **Do not execute the work yourself.** Your restricted toolset usually doesn't even include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop and create a task for the right specialist.
|
||||
- **For any concrete task, create a Kanban task and assign it.** Every single time.
|
||||
- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough."
|
||||
- **Split multi-lane requests before creating cards.** A user prompt can contain several independent workstreams. Extract those lanes first, then create one card per lane instead of bundling unrelated work into a single implementer card.
|
||||
- **Run independent lanes in parallel.** If two cards do not need each other's output, leave them unlinked so the dispatcher can fan them out. Link only true data dependencies.
|
||||
- **If no specialist fits the available profiles, ask the user which profile to create or which existing profile to use.** Do not invent profile names; the dispatcher will silently drop unknown assignees.
|
||||
- **Decompose, route, and summarize — that's the whole job.**
|
||||
|
||||
## The standard specialist roster (convention)
|
||||
|
||||
Unless the user's setup has customized profiles, assume these exist. Adjust to whatever the user actually has — ask if you're unsure.
|
||||
|
||||
| Profile | Does | Typical workspace |
|
||||
|---|---|---|
|
||||
| `researcher` | Reads sources, gathers facts, writes findings | `scratch` |
|
||||
| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs | `scratch` |
|
||||
| `writer` | Drafts prose in the user's voice | `scratch` or `dir:` into their Obsidian vault |
|
||||
| `reviewer` | Reads output, leaves findings, gates approval | `scratch` |
|
||||
| `backend-eng` | Writes server-side code | `worktree` |
|
||||
| `frontend-eng` | Writes client-side code | `worktree` |
|
||||
| `ops` | Runs scripts, manages services, handles deployments | `dir:` into ops scripts repo |
|
||||
| `pm` | Writes specs, acceptance criteria | `scratch` |
|
||||
|
||||
## Decomposition playbook
|
||||
|
||||
### Step 1 — Understand the goal
|
||||
@@ -76,43 +79,53 @@ Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to sp
|
||||
|
||||
### Step 2 — Sketch the task graph
|
||||
|
||||
Before creating anything, draft the graph out loud (in your response to the user). Example for "Analyze whether we should migrate to Postgres":
|
||||
Before creating anything, draft the graph out loud (in your response to the user). Treat every concrete workstream as a candidate card:
|
||||
|
||||
```
|
||||
T1 researcher research: Postgres cost vs current
|
||||
T2 researcher research: Postgres performance vs current
|
||||
T3 analyst synthesize migration recommendation parents: T1, T2
|
||||
T4 writer draft decision memo parents: T3
|
||||
```
|
||||
1. Extract the lanes from the request.
|
||||
2. Map each lane to one of the profiles you discovered in Step 0. If a lane doesn't fit any existing profile, ask the user which to use or create.
|
||||
3. Decide whether each lane is independent or gated by another lane.
|
||||
4. Create independent lanes as parallel cards with no parent links.
|
||||
5. Create synthesis/review/integration cards with parent links to the lanes they depend on.
|
||||
|
||||
Show this to the user. Let them correct it before you create anything.
|
||||
Examples of prompts that should fan out (using placeholder profile names — substitute whatever exists on the user's setup):
|
||||
|
||||
- "Build an app" → one card to a design-oriented profile for product/UI direction, one or two cards to engineering profiles for implementation, plus a later integration/review card if the user has a reviewer profile.
|
||||
- "Fix blockers and check model variants" → one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both.
|
||||
- "Research docs and implement" → a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings.
|
||||
- "Analyze this screenshot and find the related code" → one card to a vision-capable profile for the visual analysis while another searches the codebase.
|
||||
|
||||
Words like "also," "finally," or "and" do not automatically imply a dependency. They often mean "make sure this is covered before reporting back." Only link tasks when one card cannot start until another card's output exists.
|
||||
|
||||
Show the graph to the user before creating cards. Let them correct it — including which actual profile name should own each lane.
|
||||
|
||||
### Step 3 — Create tasks and link
|
||||
|
||||
Use the profile names from Step 0. The example below uses placeholders `<profile-A>`, `<profile-B>`, `<profile-C>` — replace them with what the user actually has.
|
||||
|
||||
```python
|
||||
t1 = kanban_create(
|
||||
title="research: Postgres cost vs current",
|
||||
assignee="researcher",
|
||||
assignee="<profile-A>", # whichever profile handles research on this setup
|
||||
body="Compare estimated infrastructure costs, migration costs, and ongoing ops costs over a 3-year window. Sources: AWS/GCP pricing, team time estimates, current Postgres bills from peers.",
|
||||
tenant=os.environ.get("HERMES_TENANT"),
|
||||
)["task_id"]
|
||||
|
||||
t2 = kanban_create(
|
||||
title="research: Postgres performance vs current",
|
||||
assignee="researcher",
|
||||
assignee="<profile-A>", # same profile, run in parallel
|
||||
body="Compare query latency, throughput, and scaling characteristics at our expected data volume (~500GB, 10k QPS peak). Sources: benchmark papers, public case studies, pgbench results if easy.",
|
||||
)["task_id"]
|
||||
|
||||
t3 = kanban_create(
|
||||
title="synthesize migration recommendation",
|
||||
assignee="analyst",
|
||||
assignee="<profile-B>", # whichever profile does synthesis/analysis
|
||||
body="Read the findings from T1 (cost) and T2 (performance). Produce a 1-page recommendation with explicit trade-offs and a go/no-go call.",
|
||||
parents=[t1, t2],
|
||||
)["task_id"]
|
||||
|
||||
t4 = kanban_create(
|
||||
title="draft decision memo",
|
||||
assignee="writer",
|
||||
assignee="<profile-C>", # whichever profile drafts user-facing prose
|
||||
body="Turn the analyst's recommendation into a 2-page memo for the CTO. Match the tone of previous decision memos in the team's knowledge base.",
|
||||
parents=[t3],
|
||||
)["task_id"]
|
||||
@@ -122,17 +135,17 @@ t4 = kanban_create(
|
||||
|
||||
### Step 4 — Complete your own task
|
||||
|
||||
If you were spawned as a task yourself (e.g. `planner` profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created:
|
||||
If you were spawned as a task yourself (e.g. a planner profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created:
|
||||
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="decomposed into T1-T4: 2 researchers parallel, 1 analyst on their outputs, 1 writer on the recommendation",
|
||||
summary="decomposed into T1-T4: 2 research lanes in parallel, 1 synthesis on their outputs, 1 prose draft on the recommendation",
|
||||
metadata={
|
||||
"task_graph": {
|
||||
"T1": {"assignee": "researcher", "parents": []},
|
||||
"T2": {"assignee": "researcher", "parents": []},
|
||||
"T3": {"assignee": "analyst", "parents": ["T1", "T2"]},
|
||||
"T4": {"assignee": "writer", "parents": ["T3"]},
|
||||
"T1": {"assignee": "<profile-A>", "parents": []},
|
||||
"T2": {"assignee": "<profile-A>", "parents": []},
|
||||
"T3": {"assignee": "<profile-B>", "parents": ["T1", "T2"]},
|
||||
"T4": {"assignee": "<profile-C>", "parents": ["T3"]},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -140,28 +153,38 @@ kanban_complete(
|
||||
|
||||
### Step 5 — Report back to the user
|
||||
|
||||
Tell them what you created in plain prose:
|
||||
Tell them what you created in plain prose, naming the actual profiles you used:
|
||||
|
||||
> I've queued 4 tasks:
|
||||
> - **T1** (researcher): cost comparison
|
||||
> - **T2** (researcher): performance comparison, in parallel with T1
|
||||
> - **T3** (analyst): synthesizes T1 + T2 into a recommendation
|
||||
> - **T4** (writer): turns T3 into a CTO memo
|
||||
> - **T1** (`<profile-A>`): cost comparison
|
||||
> - **T2** (`<profile-A>`): performance comparison, in parallel with T1
|
||||
> - **T3** (`<profile-B>`): synthesizes T1 + T2 into a recommendation
|
||||
> - **T4** (`<profile-C>`): turns T3 into a CTO memo
|
||||
>
|
||||
> The dispatcher will pick up T1 and T2 now. T3 starts when both finish. You'll get a gateway ping when T4 completes. Use the dashboard or `hermes kanban tail <id>` to follow along.
|
||||
|
||||
## Common patterns
|
||||
|
||||
**Fan-out + fan-in (research → synthesize):** N `researcher` tasks with no parents, one `analyst` task with all of them as parents.
|
||||
**Fan-out + fan-in (research → synthesize):** N research-style cards with no parents, one synthesis card with all of them as parents.
|
||||
|
||||
**Pipeline with gates:** `pm → backend-eng → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns.
|
||||
**Parallel implementation + validation:** one implementer card makes the change while one explorer/researcher card verifies config, docs, or source mapping. A reviewer card can depend on both. Do not make the implementer own unrelated verification just because the user mentioned both in one sentence.
|
||||
|
||||
**Same-profile queue:** 50 tasks, all assigned to `translator`, no dependencies between them. Dispatcher serializes — translator processes them in priority order, accumulating experience in their own memory.
|
||||
**Pipeline with gates:** `planner → implementer → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns.
|
||||
|
||||
**Same-profile queue:** N tasks, all assigned to the same profile, no dependencies between them. Dispatcher serializes — that profile processes them in priority order, accumulating experience in its own memory.
|
||||
|
||||
**Human-in-the-loop:** Any task can `kanban_block()` to wait for input. Dispatcher respawns after `/unblock`. The comment thread carries the full context.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
**Inventing profile names that don't exist.** The dispatcher silently fails to spawn unknown assignees — the card just sits in `ready` forever. Always assign to a profile from your Step 0 discovery; ask the user if you're unsure.
|
||||
|
||||
**Bundling independent lanes into one card.** If the user asks for two independent outcomes, create two cards. Example: "fix blockers and check model variants" is not one fixer task; create a fixer/engineer card for the fixes and an explorer/researcher card for the variant check, then optionally gate review on both.
|
||||
|
||||
**Over-linking because of wording.** "Finally check X" may still be parallel with implementation if X is static config, docs, or source discovery. Link it after implementation only when the check depends on the implementation result.
|
||||
|
||||
**Forgetting dependency links.** If the task graph says `research -> implement -> review`, do not create all tasks as independent ready cards. Use parent links so implement/review cannot run before their inputs exist.
|
||||
|
||||
**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile.
|
||||
|
||||
**Argument order for links.** `kanban_link(parent_id=..., child_id=...)` — parent first. Mixing them up demotes the wrong task to `todo`.
|
||||
@@ -175,7 +198,7 @@ Tell them what you created in plain prose:
|
||||
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
|
||||
|
||||
1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out.
|
||||
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker.
|
||||
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile (one that exists on this setup) and let the dispatcher pick it up with a fresh worker.
|
||||
3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model.
|
||||
|
||||
Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging.
|
||||
|
||||
Reference in New Issue
Block a user