Metadata-Version: 2.4
Name: canon-hermes-plugin
Version: 0.4.0
Summary: Canon messaging platform plugin for Hermes Agent
Author: Canon
License-Expression: MIT
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
Requires-Dist: hermes-agent<0.19,>=0.17
Requires-Dist: httpx<0.29,>=0.28

# Canon Hermes Plugin

Canon messaging platform plugin for Hermes Agent.

## Install

```bash
pip install canon-hermes-plugin
canon-hermes install --setup
```

`canon-hermes install` enables the Hermes plugin in the active Hermes
profile, enables the Canon platform, and sets `CANON_ALLOW_ALL_USERS=true` for
the first setup unless a Canon allowlist is already configured. Add `--setup` to
immediately register or reconnect a Canon agent profile. If console scripts are
not on `PATH`, use `python -m canon_hermes_plugin.cli install --setup` instead.
Canon still enforces agent identity, membership, owner approval, and conversation
policy before Hermes receives a turn.

For Canon's shared capability vocabulary across runtime adapters, tools, skills,
and UI primitives, see https://canonmail.com/agents/integration-capability-manifest.

The package also installs `canon-hermes-plugin` as a compatibility alias.

## Development

```bash
cd packages/hermes-plugin
python -m pip install -e .
python -m pytest
```

The plugin uses Canon's REST and SSE APIs. It does not require a public webhook
server and does not require npm at runtime.

## Card validation

`canon_hermes_plugin.cards` is the canonical Python validator for
`canon.card.v1` documents — a stdlib-only port of the strict TypeScript
validator in `@canonmsg/rich-cards`, kept in lockstep by shared parity
fixtures (`packages/rich-cards/fixtures/card-validation`). It also enforces
the backend's 32 KiB serialized-size cap, which the TS strict validator does
not check.

```python
from canon_hermes_plugin import validate_card, RUNTIME_CARD_LIMITS

result = validate_card(card)  # {"ok": bool, "errors": [str, ...]}
```

The `canon_runtime_control` tool validates cards with `validate_card` before
sending, so `send_card` / `request_card` fail fast with the first validator
error instead of looping against Canon 400s. Library callers importing
`request_canon_runtime_card` directly bypass that guard and must call
`validate_card` themselves.

## Reaching out to new conversations

The `canon_runtime_control` tool exposes a `reach_out` action so a Hermes agent
can open a DM with (or notify) any Canon user, not just reply in the current
conversation. It ports Canon's settled reach-out flow (`@canonmsg/core`
`reachOutToCanonContact`): resolve admission → open/reuse a direct
conversation → send, with an automatic contact-request fallback when the
target requires approval.

```json
{"action": "reach_out", "targetUserId": "<canon user id>", "text": "Invoice filed."}
```

- `text` present → opens the DM and sends it: `{status: "messaged", conversationId, messageId}`.
- `text` absent → just opens the DM: `{status: "opened", conversationId}` — follow up
  with `send_card` / `request_input` targeting `canon:<conversationId>`.
- Target requires approval → sends a contact request (`requestMessage` or `text`
  as the note): `{status: "requested", requestId}`; an already-pending request
  returns `{status: "pending", requestId}`.
- Owner-only targets return `{status: "denied", reason: "owner-only"}` — this is
  terminal; the agent must not retry or send a contact request.
- The pending contact-request cap (max 10 per requester) surfaces as an error
  with the server message.

`CanonHttpClient` gains the matching transport methods:
`resolve_admission(target_user_id)`, `create_conversation(target_user_id, ...)`,
`create_contact_request(target_user_id, message=None)`, and
`send_contextual_message(source_conversation_id, text, self_context, ...)` —
the latter requires a non-empty `self_context`
(`{'type': 'cross_session', 'context': <≤1000 chars>}`) and raises `ValueError`
without it.

## Detached (durable) approvals

The blocking gateway approval flow holds the turn open and resolves **deny** at
its deadline (30-minute ceiling) — unusable for approvals a human may answer
hours later. The `canon_runtime_control` tool adds a detached flow for those:

```json
{"action": "request_approval", "title": "File invoice", "question": "File PINVOICE 12345 for 8,200 ILS?", "context": {"Supplier": "Acme"}, "timeoutSeconds": 259200}
```

- Creates the runtime-approval (timeout clamped to **72h**) and returns
  immediately: `{status: "pending", approvalId, conversationId, expiresAt}`.
  The turn does not block and nothing cancels the request at a deadline.
- The pending approval is persisted to `~/.canon/detached-approvals.json`
  (atomic writes), so it survives container restarts; on reconnect the plugin
  reconciles entries that resolved, expired, or vanished while it was down.
- When the owner answers, the adapter intercepts the `approval_reply` receipt
  and **wakes the session with a fresh system turn**:
  `Canon approval <id> resolved: allow|deny — <question summary>`.
- Servers that still enforce the generic 30-minute expiry cap are tolerated:
  the create retries once at 30 minutes, and the effective `expiresAt` echoed
  back by the server is always the one persisted and reported.
- Session rules (`approve-all` / `approve-tool`) are disabled on detached
  approvals: one decision authorizes one write.

```json
{"action": "check_approval", "approvalId": "hermes-…"}
```

- Resolved → `{status: "resolved", decision: "allow"|"deny"}` (repeat calls
  return the cached decision from the registry).
- Still pending → `{status: "pending", expiresAt}`.
- Expired before a decision → `{status: "expired"}` — re-issue a fresh
  `request_approval` if the action still matters.
- Id no longer known (consumed elsewhere, expired and pruned, or lost) →
  `{status: "unknown"}` — **never treat this as a denial**; re-issue with a
  new request if still needed.

**At-most-once semantics, deliberately.** Runtime request ids are single-use:
consuming a decision tombstones it server-side. If the process crashes between
the consume and the guarded write it authorized, the allow is orphaned — there
is no replay; the only recovery is a full re-issue with a fresh approval id and
a fresh human decision. For financial writes this is the intended failure mode
(losing an approval is recoverable; double-filing an invoice is not).

## Turn streaming & activity trail

The plugin maps Hermes turn output onto Canon's native turn model so that a
Hermes turn renders like any other Canon agent turn:

- **Text streams into one growing bubble.** While Hermes streams, partial text
  is written to Canon's ephemeral streaming node (`POST /streaming`) — a single
  continuously-updating bubble, not a series of standalone messages.
- **Tool calls become turn activity, not chat bubbles.** `pre_tool_call` /
  `post_tool_call` runtime hooks record each tool into a bounded
  `metadata.turnTrail`, which Canon folds into the turn's "Activity — N steps"
  margin. Tool output never becomes a message bubble.
- **Only the final message notifies.** Exactly one durable
  `turnSemantics: "turn_complete"` message is sent per turn (the streaming
  finalize). Ephemeral streaming writes and turn state never push a
  notification, so recipients get a single alert per turn.

These behaviors work on **vanilla `hermes-agent`** (no upstream patch), but the
continuous-bubble + single-notification experience requires enabling gateway
streaming and suppressing Hermes's separate progress/interim messages. Add to
the gateway `config.yaml` (`~/.hermes/config.yaml`):

```yaml
streaming:
  enabled: true          # one growing streamed message per turn
  transport: auto
display:
  platforms:
    canon:
      interim_assistant_messages: false   # no mid-turn status bubbles
      tool_progress: off                  # tool progress -> turnTrail, not bubbles
```

Without this config the plugin still attaches the `turnTrail` to the final
message (tool calls remain turn activity, not bubbles) — you just won't get the
live growing bubble, and Hermes may still emit its own interim/progress
messages. The `turnTrail` is bounded to 20 blocks / 2500 bytes to stay within
Canon's 4 KB message-metadata budget.
