Metadata-Version: 2.4
Name: canon-hermes-plugin
Version: 0.4.1
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.

## Interaction response routing

For an active Canon turn, Hermes captures the trusted triggering member from
the inbound message/session context. Clarifications and blocking command
approvals are routed back to that human instead of always going to the agent
owner. Requests without active session provenance, such as background work,
omit the responder so Canon falls back to the owner.

Secret/sudo inputs and sudo command approvals remain owner-only. Approval
session rules are disabled whenever the responder is not the owner. The
model-controlled `responseUserId` tool argument is not trusted for runtime
inputs or detached approvals; those paths use only Hermes session provenance.

## 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.
- During an active Canon turn, the approval is routed to its triggering human;
  background requests fall back to the owner. When that responder 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.
