From 0914112ec9930613a89e84889e9cc3076f173176 Mon Sep 17 00:00:00 2001 From: Pavel-Tk <110731619+Pavel-Tk@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:26:30 +0000 Subject: [PATCH] feat: add Substrate v5 retrieval plugin Signed-off-by: Pavel-Tk <110731619+Pavel-Tk@users.noreply.github.com> --- README.md | 18 + docs/extraction-manifest.json | 64 +- plugins/substrate/CONTRACT.md | 251 ++ plugins/substrate/README.md | 28 + plugins/substrate/__init__.py | 5 + plugins/substrate/client.py | 83 + plugins/substrate/contract.py | 836 +++++++ .../substrate/contract/envelope-fixtures.json | 2072 +++++++++++++++++ plugins/substrate/plugin.py | 704 ++++++ plugins/substrate/plugin.yaml | 15 + scripts/verify_public_plugin_candidate.py | 9 +- tests/test_retrieval_contract.py | 230 ++ tests/test_retrieval_plugin.py | 299 +++ 13 files changed, 4610 insertions(+), 4 deletions(-) create mode 100644 plugins/substrate/CONTRACT.md create mode 100644 plugins/substrate/README.md create mode 100644 plugins/substrate/__init__.py create mode 100644 plugins/substrate/client.py create mode 100644 plugins/substrate/contract.py create mode 100644 plugins/substrate/contract/envelope-fixtures.json create mode 100644 plugins/substrate/plugin.py create mode 100644 plugins/substrate/plugin.yaml create mode 100644 tests/test_retrieval_contract.py create mode 100644 tests/test_retrieval_plugin.py diff --git a/README.md b/README.md index d220a5a..b0c741a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,24 @@ The Substrate application and server are maintained in a separate private reposi repository is the canonical editable source for public plugin/client code, verified installers, tests, documentation, and immutable release artifacts. + +## Retrieval MVP plugin (`substrate`) + +The repository also contains the thin Substrate v5 retrieval plugin in +[`plugins/substrate`](plugins/substrate). It uses the server-side candidate pool and +associative editor, injects bounded turn context, captures completed turns, and provides +`memory_search`, `memory_expand`, and `memory_evidence`. + +Install the public plugin directly from this repository: + +```sh +hermes --profile developer plugins install Substrate-memory/Substrate-memory-plugins/plugins/substrate --enable +``` + +For production, pin the command with `--ref` and a published 40-character commit SHA. +The plugin reads `SUBSTRATE_API_URL` and `SUBSTRATE_API_KEY` from the Hermes gateway +environment. See the plugin-local README for details. + ## Install with an agent Tell your Hermes agent exactly: diff --git a/docs/extraction-manifest.json b/docs/extraction-manifest.json index 83bb4a8..22aac2e 100644 --- a/docs/extraction-manifest.json +++ b/docs/extraction-manifest.json @@ -77,7 +77,7 @@ "class": "standalone_repository_policy_or_test", "path": "README.md", "reason": "Required only by the independent public repository.", - "sha256": "a5285e1800831e754b648f49f1afd8b37858a2353600db497991579de7e4f323" + "sha256": "3638d8fc00fb6328b2235dd77c00d9ba937e9b4a13cadac17f15f514f5bb9561" }, { "class": "standalone_repository_policy_or_test", @@ -115,6 +115,54 @@ "reason": "Required only by the independent public repository.", "sha256": "a0f2ffd82cd29fe3c5f551134a019d815c7df6b1198bfc95dfe456d6005c46c4" }, + { + "class": "standalone_repository_policy_or_test", + "path": "plugins/substrate/CONTRACT.md", + "reason": "Wire contract for the independent Substrate retrieval plugin.", + "sha256": "e3137a5852c16b484c55f61da8c6048d8bc29f860e3c5fd04bddc77b97cb539c" + }, + { + "class": "standalone_repository_policy_or_test", + "path": "plugins/substrate/README.md", + "reason": "Usage documentation for the independent Substrate retrieval plugin.", + "sha256": "35b846e7abdb4faad9382c015f6668d4c4bcbef1e5bb49b27b51368634cfd07c" + }, + { + "class": "standalone_plugin_implementation", + "path": "plugins/substrate/__init__.py", + "reason": "Hermes directory-plugin entry point for Substrate retrieval.", + "sha256": "df55769a899af189af4fbc78e72a5f17cc62cae27db51441b2f4bd4311bbc543" + }, + { + "class": "standalone_plugin_implementation", + "path": "plugins/substrate/client.py", + "reason": "Standard-library Substrate retrieval API client.", + "sha256": "a1b0718e6e9435d0b5530bd0baa8bdb0944c47cbdd4b74c5058267ac8eed33f8" + }, + { + "class": "standalone_plugin_implementation", + "path": "plugins/substrate/contract.py", + "reason": "Validated Substrate retrieval wire contract.", + "sha256": "f8dabf7d1e8f0c8ce5b6f1dc3529ef5d751b6b98dec1f59705392ff099e24346" + }, + { + "class": "standalone_repository_policy_or_test", + "path": "plugins/substrate/contract/envelope-fixtures.json", + "reason": "Shared retrieval wire-contract fixtures.", + "sha256": "627615398b726d04f32b5bab58b480b00ba85ca80c65d66864d7e8ea1a30ab85" + }, + { + "class": "standalone_plugin_implementation", + "path": "plugins/substrate/plugin.py", + "reason": "Hermes hooks and tools for Substrate retrieval.", + "sha256": "e7774caa2e6d6ef4f867fbd37e5fceb74750b9e30e2e0b0f4dbbac1042a78dc7" + }, + { + "class": "standalone_plugin_implementation", + "path": "plugins/substrate/plugin.yaml", + "reason": "Native Hermes manifest for Substrate retrieval.", + "sha256": "86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4" + }, { "class": "standalone_repository_policy_or_test", "path": "pyproject.toml", @@ -157,6 +205,18 @@ "reason": "Required only by the independent public repository.", "sha256": "28c3cfb895bd3e4b556aa4580da7612609d8bc3df42f4132000a9af000008d5d" }, + { + "class": "standalone_repository_policy_or_test", + "path": "tests/test_retrieval_contract.py", + "reason": "Contract tests for the independent Substrate retrieval plugin.", + "sha256": "554679f0272b59d2380d54d398ffb4f2cf23c4f1b5bf2052085f3c77e2d7e225" + }, + { + "class": "standalone_repository_policy_or_test", + "path": "tests/test_retrieval_plugin.py", + "reason": "Behavior tests for the independent Substrate retrieval plugin.", + "sha256": "ce2070dbf29cdb21a69f2af6327976a16578f51ecb7b8987c48d1ee1d8a69031" + }, { "class": "standalone_repository_policy_or_test", "path": "uv.lock", @@ -353,7 +413,7 @@ { "class": "build-and-install", "destination": "scripts/verify_public_plugin_candidate.py", - "destination_sha256": "292700ed3357372393b44371297e5368600930995479b7ff983701bed16c56a4", + "destination_sha256": "a45b9d12b6d92140c1931dc8d8193194ed8b34930f19170cce0e66fbf87d2f69", "source": "scripts/verify_public_plugin_candidate.py", "source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b", "transformation": "modified_for_standalone" diff --git a/plugins/substrate/CONTRACT.md b/plugins/substrate/CONTRACT.md new file mode 100644 index 0000000..10dc626 --- /dev/null +++ b/plugins/substrate/CONTRACT.md @@ -0,0 +1,251 @@ +# Substrate ↔ Hermes plugin wire contract + +`contract_version` **1**, `schema_version` **3**. This file is byte-identical in the plugin repository (`hermes-substrate/CONTRACT.md`) and the server repository (`Substrate-v5 experiment/CONTRACT.md`). It is implemented twice, by `src/substrate/contract.py` (plugin, Python ≥ 3.11, stdlib only) and `contract.mjs` (server, Node 22, zero dependencies), and both implementations are checked against the same fixture file `contract/envelope-fixtures.json`, also byte-identical in both repositories. + +Fixture SHA-256: 627615398b726d04f32b5bab58b480b00ba85ca80c65d66864d7e8ea1a30ab85 + +Both test suites assert that literal against the file on disk. A change to the fixture is a change to this file and to both `FIXTURE_SHA256` literals in the same commit on both sides. + +Conventions used below: all string sizes are UTF-8 byte counts; "≤ N" means at most N bytes; integers are JSON numbers without a fraction inside ±2⁵³−1; timestamps are RFC 3339 UTC `YYYY-MM-DDTHH:MM:SS[.ffffff]Z`; `hex` is lowercase; a UUID is the lowercase 8-4-4-4-12 form with version nibble 4 or 5. Objects are closed: an unknown field is a validation error unless a section says otherwise. + +## 1. Versions and negotiation + +- `contract_version` is an integer, currently `1`. Every request body the plugin builds and every response body the server returns carries it. The plugin refuses to run unless `GET /api/v1/capabilities` reports `contract_version: 1`. The server answers `400 {"error":"unsupported_contract"}` to any other value. +- `schema_version` is the envelope schema, currently `3`. The server answers `400 {"error":"unsupported_schema"}` to any other value. +- Neither version number is negotiated; both sides ship exactly one. + +## 2. Authentication and scopes + +- `Authorization: Bearer sk_sub_<32 url-safe characters>`. The server stores only `sha256(key)`. +- Scopes: `capture` (`/ledger/events`, `/ledger/upload`, `/import-status`), `retrieve` (`/memory/*`, `/pages/*`, `/jobs/*`), `admin`. The device grant issues `capture retrieve`. +- Missing or unknown key → `401 {"error":"unauthorized"}`. Known key without the scope → `403 {"error":"forbidden"}`. The plugin treats either as "reconnect": the token is deleted, onboarding restarts in the background, and spooled events stay spooled. + +## 3. Error shape + +Every non-2xx body is exactly `{"error": }`; no other fields, no free text. Categories: + +`unauthorized`, `forbidden`, `invalid_request`, `unsupported_contract`, `unsupported_schema`, `payload_too_large`, `not_found`, `conflict`, `rate_limited`, `internal`. + +HTTP status by category: 401, 403, 400, 400, 400, 413, 404, 409, 429 (with `Retry-After`), 500. The plugin additionally uses the local categories `invalid_response`, `transport_error` and `timeout`, which never appear on the wire. + +## 4. Capabilities + +`GET /api/v1/capabilities` (any scope) → + +```json +{ + "contract_version": 1, + "provider": "substrate", + "server_commit": "<≤64>", + "limits": { + "max_event_bytes": 262144, "max_upload_bytes": 262144, + "max_tool_call_bytes": 4096, "max_tool_result_bytes": 8192, + "turn_context_deadline_ms": 500, "action_cues_deadline_ms": 100, + "rules_refresh_seconds": 300 + }, + "actions": ["stored", "duplicate", "sealed", "queued"], + "kinds": ["capture_turn", "capture_session", "memory_write", "memory_forget", "consent", "page_propose", "upload"], + "tenant": {"tenant_id": "<≤128>", "brief_version": 0} +} +``` + +The plugin validates: `contract_version == 1` (else `unsupported_contract`), `provider == "substrate"`, every limit present as a non-negative integer, `actions` ⊇ the four known actions, `kinds` ⊇ the five plugin-postable kinds, `tenant.brief_version` integer ≥ 0. + +## 5. Ledger event envelope + +`POST /api/v1/ledger/events`, scope `capture`, header `Idempotency-Key` equal to the body's `event_id`, body ≤ 256 KiB. + +```json +{ + "schema_version": 3, + "contract_version": 1, + "event_id": "", + "kind": "", + "session_id": "<1..512>", + "offset": {"start": 0, "end": 0}, + "capture_origin": "live" | "history_replay" | "catchup", + "batch_id": "" | "<8..64 hex>", + "speaker": {"id": "<1..256>", "role": "owner" | "participant" | "agent", "display": "<≤256>"}, + "created_at": "", + "payload": { ... } +} +``` + +All eleven fields are required; no others are allowed. `offset.start ≥ 0`, `offset.end ≥ offset.start`. The payload contains integers only (no fractional numbers) so canonical bytes are identical across runtimes. + +### 5.1 Kinds + +Known kinds: `capture_turn`, `capture_session`, `memory_write`, `memory_forget`, `consent`, `page_propose`, `upload`. The plugin may post the first five (`PLUGIN_POSTABLE_KINDS`). `page_propose` is written by the server from `POST /pages/propose` and `upload` from `POST /ledger/upload`; either posted to `/ledger/events` is `invalid_request`. Every kind, including `consent` and `page_propose`, is stored as a version of its canonical payload bytes so replay has one uniform input. + +### 5.2 `capture_turn` + +``` +payload: {turn_id: <1..128>, messages: [1..4096 message]} +message: { + index: int ≥ 0, role: "user" | "assistant" | "tool", content: string, + timestamp?: , speaker?: , fragment?: , + tool_calls?: [≤64 tool_call] -- assistant only + tool_call_id?: <1..128>, tool_name?: <1..128>, -- tool only + result_digest: , result_bytes: int ≥ 0, result_truncated?: bool -- tool only, digest+bytes required +} +tool_call: {id: <1..128>, tool_name: <1..128>, args: object} -- canonical JSON of args ≤ 4096 + | {id, tool_name, args_truncated: true, args_sha256: , args_preview: <≤1024>} +fragment: {encoding: "utf8-content" | "canonical-json", index: int, count: int ≥ 1, sha256: } -- index < count +``` + +Rules: a tool message's `content` is the redacted excerpt, ≤ 8192 bytes, and `result_digest` is the SHA-256 of the full redacted result (equal to the digest of `content` when not truncated). `args` are redacted before sizing; the truncated form carries no `args`. Message `index` values are non-decreasing and strictly increasing except between fragments of the same message. `offset.start == messages[0].index` and `offset.end == messages[-1].index + 1`. + +### 5.3 `capture_session` (content-free) + +``` +payload: {boundary: "end" | "switch" | "reset" | "rewound" | "compress", session_complete: bool, + next_session_id?: <1..512>, parent_session_id?: <1..512>, message_high_water: int ≥ 0, + platform: <1..64>, chat_type: <1..32>, participants?: [≤64 {id: <1..256>, display: <≤256>}]} +``` + +`session_complete: true` seals the session's extraction window on the server (ACK action `sealed`). + +### 5.4 `memory_write` + +``` +payload: {text: <1..4096>, about?: <≤256>, durability: "durable" | "time_bounded" | "transient", + source: "memory_remember" | "hermes_memory_tool", action?: <≤64>, target?: <≤256>} +``` + +### 5.5 `memory_forget` + +``` +payload: {handle: , reason: <≤1024>} +``` + +### 5.6 `consent` + +``` +payload: {version: 1, scope: "hermes_history", decision: "approved" | "declined" | "revoked", + recorded_at: , includes_other_profiles: bool} +``` + +### 5.7 `page_propose` (server-written) + +``` +payload: {page_id: <1..128>, title: <1..200>, prompt: <1..4096>, session_id: <1..512>} +``` + +### 5.8 `upload` (server-written manifest) + +``` +payload: {title: <1..512>, filename?: <≤256>, sha256: , byte_size: int ≤ 262144, + source: "content" | "url", url?: } +``` + +### 5.9 Handles + +`^[mp]:[0-9a-f]{8,64}$`. `m:` is a memory unit, `p:` is a page. `memory_remember` returns the provisional handle `m:` + `sha256(event_id)[:8]`. + +### 5.10 Event ids + +- Live capture: UUID v4. +- History replay and catch-up: `uuid5(NAMESPACE, canonical_json({kind, session_id, offset, payload}))` with `NAMESPACE = 6f3a2b1c-9d8e-4f70-a1b2-c3d4e5f60718`. The name is the canonical JSON string encoded as UTF-8. Retries and resumes therefore reproduce the same id, and the server answers `duplicate` instead of storing twice. + +### 5.11 Canonical JSON + +Keys sorted recursively by Unicode code point, separators `,` and `:` with no whitespace, non-ASCII characters emitted raw (not `\uXXXX`), control characters escaped as JSON requires, integers only. Python: `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`. JS: `JSON.stringify` of a recursively key-sorted copy (code-point order). Strings must be valid Unicode scalar sequences (no lone surrogates). + +### 5.12 Server validation order + +1. Authentication → `unauthorized`. +2. Scope → `forbidden`. +3. Body size ≤ 262144 bytes → `payload_too_large`. +4. JSON parse; body must be an object → `invalid_request`. +5. `schema_version == 3` → `unsupported_schema`. +6. `contract_version == 1` → `unsupported_contract`. +7. `event_id` is a UUID and equals `Idempotency-Key` → `invalid_request`. +8. `kind` known and postable → `invalid_request`. +9. Every envelope field and the per-kind payload → `invalid_request`. + +The server never ACKs an envelope that fails any step. Both contract modules run steps 3–9 in this order on a parsed body so the plugin can predict the server's answer offline. + +## 6. ACK + +`200 {"stored": true, "event_id": "", "action": "stored" | "duplicate" | "sealed" | "queued", "job_id"?: "<≤128>"}` + +The plugin retires the spool item **iff** HTTP status is 200 **and** `stored` is boolean `true` **and** `event_id` equals the posted id **and** `action` is one of the four known actions. Anything else (a 2xx with another shape, a missing field, a string `"true"`, an unknown action, a v2-style `{ok: true}`) is a transient failure: the claim is released and the sender backs off. 401/403 trigger reconnect; the item stays spooled. `ack_ok(ack, event_id)` / `ackOk(ack, eventId)` implement exactly this rule. + +## 7. Device grant and sign-in + +- `POST /oauth/device_authorization`, form `client_id=substrate-hermes&scope=capture retrieve` → `200 {device_code: <64 hex>, user_code: "XXXX-XXXX" (alphabet BCDFGHJKLMNPQRSTVWXZ23456789), verification_uri, verification_uri_complete, expires_in: 900, interval: 5}`. +- `POST /oauth/token`, form `grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=substrate-hermes` → `400 {"error": "authorization_pending" | "slow_down" | "expired_token" | "access_denied"}` or, once, `200 {access_token: "sk_sub_…", token_type: "Bearer", scope: "capture retrieve", tenant_id, account_id}`. Polling faster than `interval` answers `slow_down` and adds 5 s to the interval. The plugin requires `token_type == "Bearer"` and the exact scope set. +- Magic-link pages: `GET /connect?user_code=` (email form) → `POST /connect/email` (`email`, `user_code`, `csrf`) → `GET /connect/verify?token=` (single-use, 15 min; creates or attaches account and tenant; sets `sub_session` HttpOnly SameSite=Lax cookie, 24 h; redirects to `/connect/approve?user_code=`) → `POST /connect/approve` (`user_code`, `csrf`, `decision=approve|deny`) binds the grant to (tenant, account). Every `/connect/*` POST requires a valid `csrf`. `/oauth/*` and `/connect/*` are rate limited to 10 requests per minute per IP → `429` with `Retry-After`. +- Development only, when `SUBSTRATE_DEV_AUTH=1`: `POST /connect/dev/approve {"user_code", "email"}` approves a grant without a browser. + +## 8. Recall routes (scope `retrieve`) + +### 8.1 `POST /api/v1/memory/turn-context` + +Request (all fields required): + +``` +{contract_version: 1, session_id: <1..512>, turn_id: <1..128>, turn: int ≥ 0, platform: <≤64>, chat_type: <≤32>, + sender_id: <≤256>, agent_identity: <≤256>, agent_context: <≤32>, parent_session_id: <≤512>, + message: <≤16384, redacted>, recent_turns: [≤2 {user: <≤4096>, assistant: <≤4096>}], + injected_handles: [≤64 handle], cited_handles: [≤64 handle], deadline_ms: 1..5000} +``` + +Response: + +``` +{contract_version: 1, session_id, turn, block: <≤8192, ≤40 lines>, handles: [≤64 handle], tail_handles: [≤64 handle], + brief_version: int ≥ 0, latency_ms: number, empty_reason: "" | "no_candidates" | "gated" | "not_implemented"} +``` + +The plugin's deadline is 500 ms wall clock. On deadline, transport error or an invalid response it injects the previous turn's block iff that block belongs to the same session and to `turn − 1`; otherwise nothing. A `brief_version` higher than the cached one triggers a background `GET /pages/pinned`. The server logs a content-free `retrieval_calls` row per request. Until the retrieval layer exists the server returns `block: ""` and `empty_reason: "not_implemented"`. + +### 8.2 `POST /api/v1/memory/action-cues` + +Request: + +``` +{contract_version: 1, session_id, turn_id, tool_call_id: <1..128>, tool_name: <1..128>, + action_class: "read" | "write" | "execute" | "network" | "deploy" | "delete" | "delegate" | "other", + artifact_keys: [≤32 {kind: "path" | "url" | "host" | "repo" | "email" | "ticket", key: <1..512>}], deadline_ms: 1..5000} +``` + +Response: `{contract_version: 1, tool_call_id, notes: [≤3 {handle, text: <1..160, single line>, enforce: bool}], latency_ms}`. Plugin deadline 100 ms; any failure yields no notes. Stub: `notes: []`. + +### 8.3 `GET /api/v1/memory/rules` + +`{contract_version: 1, rules_version: int ≥ 0, rules: [≤200 {handle, text: <1..200, single line>, action_classes: [action_class], artifact_keys: [≤32 artifact_key], enforce: true}]}`. Only enforceable rules are served; the plugin caches them locally, refreshes every 300 s, and blocks tool calls from the cache without touching the network. Stub: `rules: []`, `rules_version: 0`. + +### 8.4 Search, expand, evidence + +- `POST /memory/search {query: <1..4096>, kinds?: [≤16 <1..32>], limit?: 1..20 (default 8)}` → `{contract_version, results: [{handle, text, score, kind, markers}]}` (stub `[]`). +- `POST /memory/expand {handle}` → for `p:` `{contract_version, handle, kind: "page", title, abstract, markdown: <≤65536>}`; for `m:` `404 not_found` until retrieval exists. +- `POST /memory/evidence {handle, raw?: bool (default false), limit?: 1..20 (default 5)}` → `{contract_version, excerpts: [...], raw?: string}` (stub `[]` / `404`). + +## 9. Pages (scope `retrieve`) + +- `POST /api/v1/pages/propose {title: <1..200>, prompt: <1..4096>, session_id: <1..512>}` → `202 {contract_version, handle: "p:", page_id, status: "queued", job_id}`. The server writes a `page_propose` ledger event, a `wiki_pages` row (`page_kind='custom'`, `proposed_by_agent=1`, `created_by='agent'`) and a `render_page` job. +- `GET /api/v1/pages/pinned?scope_kind=&scope_id=` → `{contract_version, brief_version, brief: {handle, title, abstract, markdown} | null, pinned: [{handle, title, mode: "abstract" | "full", abstract, markdown?}]}`. Stub: `brief: null`, `pinned: []`. + +## 10. Upload, jobs, import status + +- `POST /api/v1/ledger/upload` (scope `capture`) `{title: <1..512>, content?: <1..262144>, url?: , filename?: <≤256>}` with exactly one of `content` / `url` → `202 {contract_version, job_id, version_id, action: "queued" | "duplicate"}`. `Idempotency-Key` replays return the stored response. Oversized `content` → `payload_too_large`. +- `GET /api/v1/jobs/{job_id}` (scope `retrieve`) → `{contract_version, job_id, kind, status: "queued" | "running" | "completed" | "failed", attempts, created_at, finished_at, error_class, result}`; another tenant's job is `404`. +- `GET /api/v1/import-status?batch_id=` (scope `capture`) → `{contract_version, batch_id, events_received, sessions_seen, sessions_completed, versions_created, extracted, extraction_failed, pending, complete, last_event_at}`. `complete` is true iff at least one event was received, every session seen was closed by a `capture_session` with `session_complete: true`, and `pending == 0`. This is the only authoritative completion signal for an import. + +## 11. Response shaping on the plugin side + +The plugin shapes every response through a per-route allowlist of top-level fields and their types (`RESPONSE_FIELDS` in `contract.py`); unknown fields are dropped, mistyped fields are dropped, and the route validators then require what they need. Nothing enters the prompt that has not passed `validate_turn_context`, `validate_action_cues` or `validate_rules`. + +## 12. Fixture file + +`contract/envelope-fixtures.json` sections: + +- `contract_version`, `schema_version`, `namespace`, `actions`, `kinds`, `plugin_postable_kinds`, `action_classes`, `error_categories` — must equal the module constants. +- `valid[]` — `{name, idempotency_key, envelope}` plus `expected_event_id` on `replay_deterministic`. Includes `turn_with_tool` (a `role: tool` message with `result_digest` and an assistant `tool_calls` entry), `group_chat_turn`, `session_end`, `session_switch`, `memory_write`, `memory_forget`, `consent`, `replay_deterministic`. +- `invalid[]` — `{name, error, envelope, idempotency_key?}`; every case must fail with exactly `error` on both sides. +- `ack.valid[]` / `ack.reject[]` — `{name, event_id, ack}`. +- `requests.turn_context`, `requests.action_cues` — valid request bodies. +- `responses.capabilities`, `responses.turn_context`, `responses.action_cues`, `responses.rules` — valid response bodies. + +Both test suites: every `valid` passes, every `invalid` fails with the listed category, the ACK table holds, `deterministic_event_id` reproduces `expected_event_id`, and `FIXTURE_SHA256 == sha256(file)`. diff --git a/plugins/substrate/README.md b/plugins/substrate/README.md new file mode 100644 index 0000000..fd6d316 --- /dev/null +++ b/plugins/substrate/README.md @@ -0,0 +1,28 @@ +# Hermes Substrate plugin + +Thin, stdlib-only adapter for the Substrate v5 retrieval API. The server owns ranking, the associative editor, storage, and evidence. The plugin caches nothing and injects nothing on any error. + +## Configure + +```bash +export SUBSTRATE_API_URL=https://app.trysubstrate.co +export SUBSTRATE_API_KEY=sk_sub_... +hermes --profile developer plugins install \ + Substrate-memory/Substrate-memory-plugins/plugins/substrate --enable +``` + +The plugin registers: + +- `pre_llm_call` for validated turn context; +- `post_llm_call` for nonblocking full completed-turn capture; +- `memory_search`, `memory_expand`, and `memory_evidence`. + +Wire schemas and limits are defined in [`CONTRACT.md`](CONTRACT.md). Runtime code has no third-party dependencies. + +## Verify + +```bash +uv run --with pytest pytest -q +cd /path/to/hermes-agent +uv run hermes plugins doctor /path/to/Substrate-memory-plugins/plugins/substrate --ci +``` diff --git a/plugins/substrate/__init__.py b/plugins/substrate/__init__.py new file mode 100644 index 0000000..922759b --- /dev/null +++ b/plugins/substrate/__init__.py @@ -0,0 +1,5 @@ +"""Hermes directory-plugin entry point for Substrate retrieval.""" + +from .plugin import register + +__all__ = ["register"] diff --git a/plugins/substrate/client.py b/plugins/substrate/client.py new file mode 100644 index 0000000..9971755 --- /dev/null +++ b/plugins/substrate/client.py @@ -0,0 +1,83 @@ +"""Small stdlib HTTP client for the Substrate API.""" + +from __future__ import annotations + +import json +import os +import socket +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +class ClientError(RuntimeError): + """A bounded local error. Backend response text is never retained.""" + + def __init__(self, category: str) -> None: + self.category = category + super().__init__(category) + + +class SubstrateClient: + """JSON-over-HTTP client with no work performed at construction time.""" + + def __init__(self, api_url: str, api_key: str) -> None: + api_url = (api_url or "").rstrip("/") + parsed = urllib.parse.urlsplit(api_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ClientError("invalid_config") + self.api_url = api_url + self.api_key = api_key or "" + + @classmethod + def from_env(cls) -> "SubstrateClient": + return cls( + os.environ.get("SUBSTRATE_API_URL", "https://app.trysubstrate.co"), + os.environ.get("SUBSTRATE_API_KEY", ""), + ) + + def post_json( + self, + path: str, + body: dict[str, Any], + *, + timeout: float, + idempotency_key: str | None = None, + max_response_bytes: int = 1_048_576, + ) -> Any: + if not self.api_key: + raise ClientError("invalid_config") + data = json.dumps( + body, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + request = urllib.request.Request( + f"{self.api_url}{path}", data=data, headers=headers, method="POST" + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = getattr(response, "status", 200) + if isinstance(status, int) and not 200 <= status < 300: + raise ClientError("transport_error") + raw = response.read(max_response_bytes + 1) + except ClientError: + raise + except (TimeoutError, socket.timeout) as exc: + raise ClientError("timeout") from exc + except (urllib.error.URLError, OSError) as exc: + raise ClientError("transport_error") from exc + except Exception as exc: + raise ClientError("transport_error") from exc + if len(raw) > max_response_bytes: + raise ClientError("invalid_response") + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ClientError("invalid_response") from exc diff --git a/plugins/substrate/contract.py b/plugins/substrate/contract.py new file mode 100644 index 0000000..55b45f9 --- /dev/null +++ b/plugins/substrate/contract.py @@ -0,0 +1,836 @@ +"""Shared wire contract between the Substrate Hermes plugin and the server. + +This module is the Python twin of ``contract.mjs`` in the server repository. +Both implement the rules written down in ``CONTRACT.md`` and both are checked +against the same fixture file (``contract/envelope-fixtures.json``), whose +SHA-256 is recorded here as ``FIXTURE_SHA256``. Standard library only. + +Every validator raises :class:`ContractError` whose ``category`` is the error +category the server would answer with (``unsupported_schema``, +``unsupported_contract``, ``payload_too_large``, ``invalid_request``) or, for +server responses validated on the plugin side, ``invalid_response``. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from pathlib import Path +from typing import Any, Mapping + +CONTRACT_VERSION = 1 +SCHEMA_VERSION = 3 + +NAMESPACE = uuid.UUID("6f3a2b1c-9d8e-4f70-a1b2-c3d4e5f60718") + +KINDS = frozenset( + { + "capture_turn", + "capture_session", + "memory_write", + "memory_forget", + "consent", + "page_propose", + "upload", + } +) +PLUGIN_POSTABLE_KINDS = frozenset( + {"capture_turn", "capture_session", "memory_write", "memory_forget", "consent"} +) +ACTIONS = frozenset({"stored", "duplicate", "sealed", "queued"}) +ACTION_CLASSES = frozenset( + {"read", "write", "execute", "network", "deploy", "delete", "delegate", "other"} +) +ARTIFACT_KEY_KINDS = frozenset({"path", "url", "host", "repo", "email", "ticket"}) +ERROR_CATEGORIES = frozenset( + { + "unauthorized", + "forbidden", + "invalid_request", + "unsupported_contract", + "unsupported_schema", + "payload_too_large", + "not_found", + "conflict", + "rate_limited", + "internal", + } +) +CLIENT_ERROR_CATEGORIES = frozenset({"invalid_response", "transport_error", "timeout"}) +CAPTURE_ORIGINS = frozenset({"live", "history_replay", "catchup"}) +SPEAKER_ROLES = frozenset({"owner", "participant", "agent"}) +MESSAGE_ROLES = frozenset({"user", "assistant", "tool"}) +BOUNDARIES = frozenset({"end", "switch", "reset", "rewound", "compress"}) +DURABILITIES = frozenset({"durable", "time_bounded", "transient"}) +MEMORY_WRITE_SOURCES = frozenset({"memory_remember", "hermes_memory_tool"}) +CONSENT_DECISIONS = frozenset({"approved", "declined", "revoked"}) +EMPTY_REASONS = frozenset({"", "no_candidates", "gated", "not_implemented"}) +FRAGMENT_ENCODINGS = frozenset({"utf8-content", "canonical-json"}) +JOB_STATUSES = frozenset({"queued", "running", "completed", "failed"}) +PIN_MODES = frozenset({"abstract", "full"}) + +LIMITS: dict[str, int] = { + "max_event_bytes": 262144, + "max_upload_bytes": 262144, + "max_tool_call_bytes": 4096, + "max_tool_result_bytes": 8192, + "turn_context_deadline_ms": 500, + "action_cues_deadline_ms": 100, + "rules_refresh_seconds": 300, +} + +MAX_SESSION_ID_BYTES = 512 +MAX_ID_BYTES = 128 +MAX_SPEAKER_ID_BYTES = 256 +MAX_DISPLAY_BYTES = 256 +MAX_TEXT_BYTES = 4096 +MAX_ABOUT_BYTES = 256 +MAX_REASON_BYTES = 1024 +MAX_ARGS_PREVIEW_BYTES = 1024 +MAX_TITLE_BYTES = 200 +MAX_UPLOAD_TITLE_BYTES = 512 +MAX_PROMPT_BYTES = 4096 +MAX_QUERY_BYTES = 4096 +MAX_FILENAME_BYTES = 256 +MAX_URL_BYTES = 2048 +MAX_BLOCK_BYTES = 8192 +MAX_BLOCK_LINES = 40 +MAX_TURN_MESSAGE_BYTES = 16384 +MAX_RECENT_TURN_BYTES = 4096 +MAX_RECENT_TURNS = 2 +MAX_HANDLES = 64 +MAX_NOTES = 3 +MAX_NOTE_TEXT_BYTES = 160 +MAX_RULES = 200 +MAX_RULE_TEXT_BYTES = 200 +MAX_ARTIFACT_KEYS = 32 +MAX_ARTIFACT_KEY_BYTES = 512 +MAX_PARTICIPANTS = 64 +MAX_PLATFORM_BYTES = 64 +MAX_CHAT_TYPE_BYTES = 32 +MAX_AGENT_CONTEXT_BYTES = 32 +MAX_DEADLINE_MS = 5000 +MAX_SEARCH_LIMIT = 20 +MAX_SEARCH_KINDS = 16 +MAX_MARKDOWN_BYTES = 65536 +MAX_SAFE_INTEGER = 2**53 - 1 + +HANDLE_RE = re.compile(r"^[mp]:[0-9a-f]{8,64}$") +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[45][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +BATCH_ID_RE = re.compile(r"^[0-9a-f]{8,64}$") +RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$") +HTTPS_URL_RE = re.compile(r"^https://[^\s/?#]+[^\s]*$") + +# Literal SHA-256 of contract/envelope-fixtures.json. Asserted by the tests. +FIXTURE_SHA256 = "627615398b726d04f32b5bab58b480b00ba85ca80c65d66864d7e8ea1a30ab85" + +_FIXTURE_PATH = Path(__file__).resolve().parent / "contract" / "envelope-fixtures.json" + + +class ContractError(ValueError): + """A contract violation; ``category`` is the wire error category.""" + + def __init__(self, category: str, detail: str = "") -> None: + self.category = category + self.detail = detail + super().__init__(f"{category}: {detail}" if detail else category) + + +# -------------------------------------------------------------------------- +# Canonical JSON and deterministic ids +# -------------------------------------------------------------------------- + + +def canonical_json(obj: Any) -> str: + """Canonical JSON: sorted keys, no whitespace, raw (non-ASCII) UTF-8.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def canonical_bytes(obj: Any) -> bytes: + return canonical_json(obj).encode("utf-8") + + +def deterministic_event_id( + kind: str, session_id: str, offset: Mapping[str, int], payload: Any +) -> str: + """uuid5(NAMESPACE, canonical_json({kind, session_id, offset, payload})).""" + name = canonical_json( + { + "kind": kind, + "session_id": session_id, + "offset": {"start": offset["start"], "end": offset["end"]}, + "payload": payload, + } + ) + return str(uuid.uuid5(NAMESPACE, name)) + + +# -------------------------------------------------------------------------- +# Primitive checks. Each raises ContractError(category, path). +# -------------------------------------------------------------------------- + + +def _byte_len(value: str) -> int: + return len(value.encode("utf-8")) + + +def _fail(category: str, path: str, why: str) -> None: + raise ContractError(category, f"{path}: {why}") + + +def _obj(value: Any, path: str, allowed: frozenset[str] | set[str], required: set[str], + category: str = "invalid_request") -> dict[str, Any]: + if not isinstance(value, dict): + _fail(category, path, "expected object") + for key in value: + if not isinstance(key, str) or key not in allowed: + _fail(category, f"{path}.{key}", "unknown field") + for key in required: + if key not in value: + _fail(category, f"{path}.{key}", "missing") + return value + + +def _str(value: Any, path: str, *, max_bytes: int, min_bytes: int = 0, + category: str = "invalid_request") -> str: + if not isinstance(value, str): + _fail(category, path, "expected string") + size = _byte_len(value) + if size < min_bytes: + _fail(category, path, f"shorter than {min_bytes} bytes") + if size > max_bytes: + _fail(category, path, f"longer than {max_bytes} bytes") + return value + + +def _int(value: Any, path: str, *, minimum: int = 0, maximum: int = MAX_SAFE_INTEGER, + category: str = "invalid_request") -> int: + if isinstance(value, bool) or not isinstance(value, int): + _fail(category, path, "expected integer") + if value < minimum or value > maximum: + _fail(category, path, f"out of range [{minimum}, {maximum}]") + return value + + +def _number(value: Any, path: str, *, minimum: float = 0.0, + category: str = "invalid_request") -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + _fail(category, path, "expected number") + if value != value or value < minimum: # NaN or below minimum + _fail(category, path, "out of range") + return value + + +def _bool(value: Any, path: str, category: str = "invalid_request") -> bool: + if not isinstance(value, bool): + _fail(category, path, "expected boolean") + return value + + +def _enum(value: Any, path: str, allowed: frozenset[str], + category: str = "invalid_request") -> str: + if not isinstance(value, str) or value not in allowed: + _fail(category, path, f"expected one of {sorted(allowed)}") + return value + + +def _match(value: Any, path: str, pattern: re.Pattern[str], *, max_bytes: int = 4096, + category: str = "invalid_request") -> str: + if not isinstance(value, str) or _byte_len(value) > max_bytes or not pattern.match(value): + _fail(category, path, f"does not match {pattern.pattern}") + return value + + +def _list(value: Any, path: str, *, max_items: int, min_items: int = 0, + category: str = "invalid_request") -> list[Any]: + if not isinstance(value, list): + _fail(category, path, "expected array") + if len(value) < min_items: + _fail(category, path, f"fewer than {min_items} items") + if len(value) > max_items: + _fail(category, path, f"more than {max_items} items") + return value + + +def _handles(value: Any, path: str, category: str = "invalid_request") -> list[str]: + items = _list(value, path, max_items=MAX_HANDLES, category=category) + return [_match(item, f"{path}[{i}]", HANDLE_RE, category=category) + for i, item in enumerate(items)] + + +def _no_floats(value: Any, path: str, category: str = "invalid_request") -> None: + """Envelopes carry integers only so canonical JSON is byte-identical across runtimes.""" + if isinstance(value, bool): + return + if isinstance(value, float): + _fail(category, path, "non-integer number") + if isinstance(value, int) and abs(value) > MAX_SAFE_INTEGER: + _fail(category, path, "integer outside +/-2^53-1") + if isinstance(value, dict): + for key, child in value.items(): + if not isinstance(key, str): + _fail(category, path, "non-string key") + _no_floats(child, f"{path}.{key}", category) + elif isinstance(value, list): + for i, child in enumerate(value): + _no_floats(child, f"{path}[{i}]", category) + + +# -------------------------------------------------------------------------- +# Envelope +# -------------------------------------------------------------------------- + +_ENVELOPE_FIELDS = frozenset( + {"schema_version", "contract_version", "event_id", "kind", "session_id", "offset", + "capture_origin", "batch_id", "speaker", "created_at", "payload"} +) +_SPEAKER_FIELDS = frozenset({"id", "role", "display"}) +_MESSAGE_FIELDS = frozenset( + {"index", "role", "content", "timestamp", "speaker", "tool_calls", "tool_call_id", + "tool_name", "result_digest", "result_bytes", "result_truncated", "fragment"} +) +_TOOL_CALL_FIELDS = frozenset( + {"id", "tool_name", "args", "args_truncated", "args_sha256", "args_preview"} +) +_FRAGMENT_FIELDS = frozenset({"encoding", "index", "count", "sha256"}) +_TURN_FIELDS = frozenset({"turn_id", "messages"}) +_SESSION_FIELDS = frozenset( + {"boundary", "session_complete", "next_session_id", "parent_session_id", + "message_high_water", "platform", "chat_type", "participants"} +) +_PARTICIPANT_FIELDS = frozenset({"id", "display"}) +_MEMORY_WRITE_FIELDS = frozenset({"text", "about", "durability", "source", "action", "target"}) +_MEMORY_FORGET_FIELDS = frozenset({"handle", "reason"}) +_CONSENT_FIELDS = frozenset( + {"version", "scope", "decision", "recorded_at", "includes_other_profiles"} +) +_PAGE_PROPOSE_FIELDS = frozenset({"page_id", "title", "prompt", "session_id"}) +_UPLOAD_FIELDS = frozenset({"title", "filename", "sha256", "byte_size", "source", "url"}) + + +def _speaker(value: Any, path: str) -> dict[str, Any]: + _obj(value, path, _SPEAKER_FIELDS, {"id", "role", "display"}) + _str(value["id"], f"{path}.id", max_bytes=MAX_SPEAKER_ID_BYTES, min_bytes=1) + _enum(value["role"], f"{path}.role", SPEAKER_ROLES) + _str(value["display"], f"{path}.display", max_bytes=MAX_DISPLAY_BYTES) + return value + + +def _tool_call(value: Any, path: str) -> None: + _obj(value, path, _TOOL_CALL_FIELDS, {"id", "tool_name"}) + _str(value["id"], f"{path}.id", max_bytes=MAX_ID_BYTES, min_bytes=1) + _str(value["tool_name"], f"{path}.tool_name", max_bytes=MAX_ID_BYTES, min_bytes=1) + truncated = value.get("args_truncated", False) + _bool(truncated, f"{path}.args_truncated") + if truncated: + if "args" in value: + _fail("invalid_request", f"{path}.args", "present in truncated form") + if "args_sha256" not in value or "args_preview" not in value: + _fail("invalid_request", path, "truncated form requires args_sha256 and args_preview") + _match(value["args_sha256"], f"{path}.args_sha256", SHA256_RE) + _str(value["args_preview"], f"{path}.args_preview", max_bytes=MAX_ARGS_PREVIEW_BYTES) + return + if "args_sha256" in value or "args_preview" in value: + _fail("invalid_request", path, "args_sha256/args_preview only in truncated form") + if "args" not in value or not isinstance(value["args"], dict): + _fail("invalid_request", f"{path}.args", "expected object") + if len(canonical_bytes(value["args"])) > LIMITS["max_tool_call_bytes"]: + _fail("invalid_request", f"{path}.args", + f"canonical JSON exceeds {LIMITS['max_tool_call_bytes']} bytes") + + +def _fragment(value: Any, path: str) -> None: + _obj(value, path, _FRAGMENT_FIELDS, {"encoding", "index", "count", "sha256"}) + _enum(value["encoding"], f"{path}.encoding", FRAGMENT_ENCODINGS) + count = _int(value["count"], f"{path}.count", minimum=1) + _int(value["index"], f"{path}.index", minimum=0, maximum=count - 1) + _match(value["sha256"], f"{path}.sha256", SHA256_RE) + + +def _message(value: Any, path: str) -> dict[str, Any]: + _obj(value, path, _MESSAGE_FIELDS, {"index", "role", "content"}) + _int(value["index"], f"{path}.index", minimum=0) + role = _enum(value["role"], f"{path}.role", MESSAGE_ROLES) + content = _str(value["content"], f"{path}.content", max_bytes=LIMITS["max_event_bytes"]) + if "timestamp" in value: + _match(value["timestamp"], f"{path}.timestamp", RFC3339_RE) + if "speaker" in value: + _speaker(value["speaker"], f"{path}.speaker") + if "fragment" in value: + _fragment(value["fragment"], f"{path}.fragment") + if "tool_calls" in value: + if role != "assistant": + _fail("invalid_request", f"{path}.tool_calls", "only on assistant messages") + calls = _list(value["tool_calls"], f"{path}.tool_calls", max_items=64) + for i, call in enumerate(calls): + _tool_call(call, f"{path}.tool_calls[{i}]") + tool_only = {"tool_call_id", "tool_name", "result_digest", "result_bytes", "result_truncated"} + if role == "tool": + if _byte_len(content) > LIMITS["max_tool_result_bytes"]: + _fail("invalid_request", f"{path}.content", + f"tool result excerpt exceeds {LIMITS['max_tool_result_bytes']} bytes") + for key in ("result_digest", "result_bytes"): + if key not in value: + _fail("invalid_request", f"{path}.{key}", "missing on tool message") + _match(value["result_digest"], f"{path}.result_digest", SHA256_RE) + _int(value["result_bytes"], f"{path}.result_bytes", minimum=0) + if "result_truncated" in value: + _bool(value["result_truncated"], f"{path}.result_truncated") + if "tool_call_id" in value: + _str(value["tool_call_id"], f"{path}.tool_call_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + if "tool_name" in value: + _str(value["tool_name"], f"{path}.tool_name", max_bytes=MAX_ID_BYTES, min_bytes=1) + else: + for key in tool_only: + if key in value: + _fail("invalid_request", f"{path}.{key}", "only on tool messages") + return value + + +def _payload_capture_turn(payload: Any, offset: Mapping[str, int], path: str) -> None: + _obj(payload, path, _TURN_FIELDS, {"turn_id", "messages"}) + _str(payload["turn_id"], f"{path}.turn_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + messages = _list(payload["messages"], f"{path}.messages", max_items=4096, min_items=1) + previous = -1 + previous_fragment = False + for i, message in enumerate(messages): + _message(message, f"{path}.messages[{i}]") + index = message["index"] + has_fragment = "fragment" in message + if index < previous or (index == previous and not (has_fragment and previous_fragment)): + _fail("invalid_request", f"{path}.messages[{i}].index", "indices must increase") + previous, previous_fragment = index, has_fragment + if offset["start"] != messages[0]["index"] or offset["end"] != messages[-1]["index"] + 1: + _fail("invalid_request", "offset", "must span messages[0].index .. messages[-1].index+1") + + +def _payload_capture_session(payload: Any, path: str) -> None: + _obj(payload, path, _SESSION_FIELDS, + {"boundary", "session_complete", "message_high_water", "platform", "chat_type"}) + _enum(payload["boundary"], f"{path}.boundary", BOUNDARIES) + _bool(payload["session_complete"], f"{path}.session_complete") + _int(payload["message_high_water"], f"{path}.message_high_water", minimum=0) + _str(payload["platform"], f"{path}.platform", max_bytes=MAX_PLATFORM_BYTES, min_bytes=1) + _str(payload["chat_type"], f"{path}.chat_type", max_bytes=MAX_CHAT_TYPE_BYTES, min_bytes=1) + for key in ("next_session_id", "parent_session_id"): + if key in payload: + _str(payload[key], f"{path}.{key}", max_bytes=MAX_SESSION_ID_BYTES, min_bytes=1) + if "participants" in payload: + items = _list(payload["participants"], f"{path}.participants", max_items=MAX_PARTICIPANTS) + for i, item in enumerate(items): + ipath = f"{path}.participants[{i}]" + _obj(item, ipath, _PARTICIPANT_FIELDS, {"id", "display"}) + _str(item["id"], f"{ipath}.id", max_bytes=MAX_SPEAKER_ID_BYTES, min_bytes=1) + _str(item["display"], f"{ipath}.display", max_bytes=MAX_DISPLAY_BYTES) + + +def _payload_memory_write(payload: Any, path: str) -> None: + _obj(payload, path, _MEMORY_WRITE_FIELDS, {"text", "durability", "source"}) + _str(payload["text"], f"{path}.text", max_bytes=MAX_TEXT_BYTES, min_bytes=1) + _enum(payload["durability"], f"{path}.durability", DURABILITIES) + _enum(payload["source"], f"{path}.source", MEMORY_WRITE_SOURCES) + if "about" in payload: + _str(payload["about"], f"{path}.about", max_bytes=MAX_ABOUT_BYTES) + if "action" in payload: + _str(payload["action"], f"{path}.action", max_bytes=64) + if "target" in payload: + _str(payload["target"], f"{path}.target", max_bytes=MAX_ABOUT_BYTES) + + +def _payload_memory_forget(payload: Any, path: str) -> None: + _obj(payload, path, _MEMORY_FORGET_FIELDS, {"handle", "reason"}) + _match(payload["handle"], f"{path}.handle", HANDLE_RE) + _str(payload["reason"], f"{path}.reason", max_bytes=MAX_REASON_BYTES) + + +def _payload_consent(payload: Any, path: str) -> None: + _obj(payload, path, _CONSENT_FIELDS, + {"version", "scope", "decision", "recorded_at", "includes_other_profiles"}) + _int(payload["version"], f"{path}.version", minimum=1, maximum=1) + _enum(payload["scope"], f"{path}.scope", frozenset({"hermes_history"})) + _enum(payload["decision"], f"{path}.decision", CONSENT_DECISIONS) + _match(payload["recorded_at"], f"{path}.recorded_at", RFC3339_RE) + _bool(payload["includes_other_profiles"], f"{path}.includes_other_profiles") + + +def _payload_page_propose(payload: Any, path: str) -> None: + _obj(payload, path, _PAGE_PROPOSE_FIELDS, {"page_id", "title", "prompt", "session_id"}) + _str(payload["page_id"], f"{path}.page_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + _str(payload["title"], f"{path}.title", max_bytes=MAX_TITLE_BYTES, min_bytes=1) + _str(payload["prompt"], f"{path}.prompt", max_bytes=MAX_PROMPT_BYTES, min_bytes=1) + _str(payload["session_id"], f"{path}.session_id", max_bytes=MAX_SESSION_ID_BYTES, min_bytes=1) + + +def _payload_upload(payload: Any, path: str) -> None: + _obj(payload, path, _UPLOAD_FIELDS, {"title", "sha256", "byte_size", "source"}) + _str(payload["title"], f"{path}.title", max_bytes=MAX_UPLOAD_TITLE_BYTES, min_bytes=1) + _match(payload["sha256"], f"{path}.sha256", SHA256_RE) + _int(payload["byte_size"], f"{path}.byte_size", minimum=0, maximum=LIMITS["max_upload_bytes"]) + source = _enum(payload["source"], f"{path}.source", frozenset({"content", "url"})) + if "filename" in payload: + _str(payload["filename"], f"{path}.filename", max_bytes=MAX_FILENAME_BYTES) + if source == "url": + if "url" not in payload: + _fail("invalid_request", f"{path}.url", "missing for source=url") + _match(payload["url"], f"{path}.url", HTTPS_URL_RE, max_bytes=MAX_URL_BYTES) + elif "url" in payload: + _fail("invalid_request", f"{path}.url", "only for source=url") + + +def validate_envelope( + env: Any, + *, + idempotency_key: str | None = None, + allowed_kinds: frozenset[str] = PLUGIN_POSTABLE_KINDS, +) -> None: + """Validate a ledger event envelope in the server's order. + + Raises :class:`ContractError` with the category the server would return: + ``payload_too_large`` (canonical bytes over ``max_event_bytes``), + ``unsupported_schema``, ``unsupported_contract``, then ``invalid_request`` + for every structural problem. ``idempotency_key`` when given must equal + ``event_id``. ``allowed_kinds`` defaults to what the plugin may post; + the server uses ``KINDS`` when validating events it writes itself. + """ + if not isinstance(env, dict): + raise ContractError("invalid_request", "body: expected object") + if len(canonical_bytes(env)) > LIMITS["max_event_bytes"]: + raise ContractError("payload_too_large", "body: exceeds max_event_bytes") + schema = env.get("schema_version") + if isinstance(schema, bool) or schema != SCHEMA_VERSION: + raise ContractError("unsupported_schema", f"schema_version: {schema!r}") + contract = env.get("contract_version") + if isinstance(contract, bool) or contract != CONTRACT_VERSION: + raise ContractError("unsupported_contract", f"contract_version: {contract!r}") + _obj(env, "body", _ENVELOPE_FIELDS, set(_ENVELOPE_FIELDS)) + event_id = _match(env["event_id"], "event_id", UUID_RE) + if idempotency_key is not None and idempotency_key != event_id: + _fail("invalid_request", "event_id", "does not match Idempotency-Key") + kind = env["kind"] + if not isinstance(kind, str) or kind not in KINDS: + _fail("invalid_request", "kind", "unknown kind") + if kind not in allowed_kinds: + _fail("invalid_request", "kind", "not postable on this route") + _str(env["session_id"], "session_id", max_bytes=MAX_SESSION_ID_BYTES, min_bytes=1) + offset = _obj(env["offset"], "offset", frozenset({"start", "end"}), {"start", "end"}) + start = _int(offset["start"], "offset.start", minimum=0) + _int(offset["end"], "offset.end", minimum=start) + _enum(env["capture_origin"], "capture_origin", CAPTURE_ORIGINS) + batch_id = env["batch_id"] + if not isinstance(batch_id, str) or (batch_id != "" and not BATCH_ID_RE.match(batch_id)): + _fail("invalid_request", "batch_id", "expected empty string or 8..64 hex") + _speaker(env["speaker"], "speaker") + _match(env["created_at"], "created_at", RFC3339_RE) + payload = env["payload"] + if not isinstance(payload, dict): + _fail("invalid_request", "payload", "expected object") + _no_floats(payload, "payload") + if kind == "capture_turn": + _payload_capture_turn(payload, offset, "payload") + elif kind == "capture_session": + _payload_capture_session(payload, "payload") + elif kind == "memory_write": + _payload_memory_write(payload, "payload") + elif kind == "memory_forget": + _payload_memory_forget(payload, "payload") + elif kind == "consent": + _payload_consent(payload, "payload") + elif kind == "page_propose": + _payload_page_propose(payload, "payload") + elif kind == "upload": + _payload_upload(payload, "payload") + + +def ack_ok(ack: Any, event_id: str) -> bool: + """True iff the ACK retires the spool item (stored, same id, known action).""" + if not isinstance(ack, dict): + return False + if ack.get("stored") is not True: + return False + if ack.get("event_id") != event_id: + return False + action = ack.get("action") + return isinstance(action, str) and action in ACTIONS + + +# -------------------------------------------------------------------------- +# Requests built by the plugin +# -------------------------------------------------------------------------- + +_TURN_CONTEXT_REQUEST_FIELDS = frozenset( + {"contract_version", "session_id", "turn_id", "turn", "platform", "chat_type", + "sender_id", "agent_identity", "agent_context", "parent_session_id", "message", + "recent_turns", "injected_handles", "cited_handles", "deadline_ms"} +) +_ACTION_CUES_REQUEST_FIELDS = frozenset( + {"contract_version", "session_id", "turn_id", "tool_call_id", "tool_name", + "action_class", "artifact_keys", "deadline_ms"} +) + + +def _contract_version_field(value: Any, path: str, category: str = "invalid_request") -> None: + if isinstance(value, bool) or value != CONTRACT_VERSION: + _fail("unsupported_contract" if category == "invalid_request" else category, + f"{path}.contract_version", "must be 1") + + +def _artifact_keys(value: Any, path: str, category: str = "invalid_request") -> None: + items = _list(value, path, max_items=MAX_ARTIFACT_KEYS, category=category) + for i, item in enumerate(items): + ipath = f"{path}[{i}]" + _obj(item, ipath, frozenset({"kind", "key"}), {"kind", "key"}, category) + _enum(item["kind"], f"{ipath}.kind", ARTIFACT_KEY_KINDS, category) + _str(item["key"], f"{ipath}.key", max_bytes=MAX_ARTIFACT_KEY_BYTES, min_bytes=1, + category=category) + + +def validate_turn_context_request(req: Any) -> dict[str, Any]: + _obj(req, "body", _TURN_CONTEXT_REQUEST_FIELDS, set(_TURN_CONTEXT_REQUEST_FIELDS)) + _contract_version_field(req["contract_version"], "body") + _str(req["session_id"], "session_id", max_bytes=MAX_SESSION_ID_BYTES, min_bytes=1) + _str(req["turn_id"], "turn_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + _int(req["turn"], "turn", minimum=0) + _str(req["platform"], "platform", max_bytes=MAX_PLATFORM_BYTES) + _str(req["chat_type"], "chat_type", max_bytes=MAX_CHAT_TYPE_BYTES) + _str(req["sender_id"], "sender_id", max_bytes=MAX_SPEAKER_ID_BYTES) + _str(req["agent_identity"], "agent_identity", max_bytes=MAX_SPEAKER_ID_BYTES) + _str(req["agent_context"], "agent_context", max_bytes=MAX_AGENT_CONTEXT_BYTES) + _str(req["parent_session_id"], "parent_session_id", max_bytes=MAX_SESSION_ID_BYTES) + _str(req["message"], "message", max_bytes=MAX_TURN_MESSAGE_BYTES) + turns = _list(req["recent_turns"], "recent_turns", max_items=MAX_RECENT_TURNS) + for i, turn in enumerate(turns): + tpath = f"recent_turns[{i}]" + _obj(turn, tpath, frozenset({"user", "assistant"}), {"user", "assistant"}) + _str(turn["user"], f"{tpath}.user", max_bytes=MAX_RECENT_TURN_BYTES) + _str(turn["assistant"], f"{tpath}.assistant", max_bytes=MAX_RECENT_TURN_BYTES) + _handles(req["injected_handles"], "injected_handles") + _handles(req["cited_handles"], "cited_handles") + _int(req["deadline_ms"], "deadline_ms", minimum=1, maximum=MAX_DEADLINE_MS) + return req + + +def validate_action_cues_request(req: Any) -> dict[str, Any]: + _obj(req, "body", _ACTION_CUES_REQUEST_FIELDS, set(_ACTION_CUES_REQUEST_FIELDS)) + _contract_version_field(req["contract_version"], "body") + _str(req["session_id"], "session_id", max_bytes=MAX_SESSION_ID_BYTES, min_bytes=1) + _str(req["turn_id"], "turn_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + _str(req["tool_call_id"], "tool_call_id", max_bytes=MAX_ID_BYTES, min_bytes=1) + _str(req["tool_name"], "tool_name", max_bytes=MAX_ID_BYTES, min_bytes=1) + _enum(req["action_class"], "action_class", ACTION_CLASSES) + _artifact_keys(req["artifact_keys"], "artifact_keys") + _int(req["deadline_ms"], "deadline_ms", minimum=1, maximum=MAX_DEADLINE_MS) + return req + + +# -------------------------------------------------------------------------- +# Responses consumed by the plugin (category: invalid_response) +# -------------------------------------------------------------------------- + +_R = "invalid_response" + +# Per-route allowlists of top-level response fields and their accepted types. +# Used by the client to shape responses before anything else looks at them. +RESPONSE_FIELDS: dict[str, dict[str, tuple[type, ...]]] = { + "capabilities": { + "contract_version": (int,), "provider": (str,), "server_commit": (str,), + "limits": (dict,), "actions": (list,), "kinds": (list,), "tenant": (dict,), + }, + "events": {"stored": (bool,), "event_id": (str,), "action": (str,), "job_id": (str,)}, + "turn_context": { + "contract_version": (int,), "session_id": (str,), "turn": (int,), "block": (str,), + "handles": (list,), "tail_handles": (list,), "brief_version": (int,), + "latency_ms": (int, float), "empty_reason": (str,), + }, + "action_cues": { + "contract_version": (int,), "tool_call_id": (str,), "notes": (list,), + "latency_ms": (int, float), + }, + "rules": {"contract_version": (int,), "rules_version": (int,), "rules": (list,)}, + "search": {"contract_version": (int,), "results": (list,)}, + "expand": { + "contract_version": (int,), "handle": (str,), "kind": (str,), "title": (str,), + "abstract": (str,), "markdown": (str,), + }, + "evidence": {"contract_version": (int,), "excerpts": (list,), "raw": (str,)}, + "propose": { + "contract_version": (int,), "handle": (str,), "page_id": (str,), "status": (str,), + "job_id": (str,), + }, + "pinned": { + "contract_version": (int,), "brief_version": (int,), "brief": (dict, type(None)), + "pinned": (list,), + }, + "upload": {"contract_version": (int,), "job_id": (str,), "version_id": (str,), "action": (str,)}, + "job_status": { + "contract_version": (int,), "job_id": (str,), "kind": (str,), "status": (str,), + "attempts": (int,), "created_at": (str,), "finished_at": (str, type(None)), + "error_class": (str, type(None)), "result": (dict, type(None)), + }, + "import_status": { + "contract_version": (int,), "batch_id": (str,), "events_received": (int,), + "sessions_seen": (int,), "sessions_completed": (int,), "versions_created": (int,), + "extracted": (int,), "extraction_failed": (int,), "pending": (int,), + "complete": (bool,), "last_event_at": (str, type(None)), + }, +} + + +def shape_response(route: str, value: Any) -> dict[str, Any]: + """Keep only allowlisted top-level fields whose type matches; drop the rest.""" + fields = RESPONSE_FIELDS[route] + if not isinstance(value, dict): + raise ContractError(_R, f"{route}: expected object") + shaped: dict[str, Any] = {} + for key, types in fields.items(): + if key not in value: + continue + child = value[key] + if isinstance(child, bool) and bool not in types: + continue + if isinstance(child, types): + shaped[key] = child + return shaped + + +def validate_capabilities(caps: Any) -> dict[str, Any]: + """Validate ``GET /api/v1/capabilities``; ``unsupported_contract`` on version mismatch.""" + if not isinstance(caps, dict): + raise ContractError(_R, "capabilities: expected object") + version = caps.get("contract_version") + if isinstance(version, bool) or version != CONTRACT_VERSION: + raise ContractError("unsupported_contract", f"capabilities.contract_version: {version!r}") + shaped = shape_response("capabilities", caps) + for key in ("provider", "server_commit", "limits", "actions", "kinds", "tenant"): + if key not in shaped: + _fail(_R, f"capabilities.{key}", "missing") + if shaped["provider"] != "substrate": + _fail(_R, "capabilities.provider", "expected 'substrate'") + _str(shaped["server_commit"], "capabilities.server_commit", max_bytes=64, category=_R) + limits = shaped["limits"] + for key in LIMITS: + if key not in limits: + _fail(_R, f"capabilities.limits.{key}", "missing") + _int(limits[key], f"capabilities.limits.{key}", minimum=0, category=_R) + actions = _list(shaped["actions"], "capabilities.actions", max_items=16, category=_R) + for i, action in enumerate(actions): + _str(action, f"capabilities.actions[{i}]", max_bytes=32, min_bytes=1, category=_R) + if not ACTIONS.issubset(actions): + _fail(_R, "capabilities.actions", "must include every known action") + kinds = _list(shaped["kinds"], "capabilities.kinds", max_items=32, category=_R) + for i, kind in enumerate(kinds): + _str(kind, f"capabilities.kinds[{i}]", max_bytes=32, min_bytes=1, category=_R) + if not PLUGIN_POSTABLE_KINDS.issubset(kinds): + _fail(_R, "capabilities.kinds", "must include every plugin-postable kind") + tenant = _obj(shaped["tenant"], "capabilities.tenant", frozenset({"tenant_id", "brief_version"}), + {"tenant_id", "brief_version"}, _R) + _str(tenant["tenant_id"], "capabilities.tenant.tenant_id", max_bytes=MAX_ID_BYTES, min_bytes=1, + category=_R) + _int(tenant["brief_version"], "capabilities.tenant.brief_version", minimum=0, category=_R) + return shaped + + +def validate_turn_context(resp: Any) -> dict[str, Any]: + """Validate ``POST /memory/turn-context`` before anything enters the prompt.""" + shaped = shape_response("turn_context", resp) + for key in RESPONSE_FIELDS["turn_context"]: + if key not in shaped: + _fail(_R, f"turn_context.{key}", "missing or wrong type") + _contract_version_field(shaped["contract_version"], "turn_context", _R) + _str(shaped["session_id"], "turn_context.session_id", max_bytes=MAX_SESSION_ID_BYTES, + min_bytes=1, category=_R) + _int(shaped["turn"], "turn_context.turn", minimum=0, category=_R) + block = _str(shaped["block"], "turn_context.block", max_bytes=MAX_BLOCK_BYTES, category=_R) + if block.count("\n") + (1 if block else 0) > MAX_BLOCK_LINES: + _fail(_R, "turn_context.block", f"more than {MAX_BLOCK_LINES} lines") + _handles(shaped["handles"], "turn_context.handles", _R) + _handles(shaped["tail_handles"], "turn_context.tail_handles", _R) + _int(shaped["brief_version"], "turn_context.brief_version", minimum=0, category=_R) + _number(shaped["latency_ms"], "turn_context.latency_ms", category=_R) + _enum(shaped["empty_reason"], "turn_context.empty_reason", EMPTY_REASONS, _R) + return shaped + + +def validate_action_cues(resp: Any) -> dict[str, Any]: + """Validate ``POST /memory/action-cues``; notes are bounded to 3 x 160 bytes.""" + shaped = shape_response("action_cues", resp) + for key in RESPONSE_FIELDS["action_cues"]: + if key not in shaped: + _fail(_R, f"action_cues.{key}", "missing or wrong type") + _contract_version_field(shaped["contract_version"], "action_cues", _R) + _str(shaped["tool_call_id"], "action_cues.tool_call_id", max_bytes=MAX_ID_BYTES, min_bytes=1, + category=_R) + notes = _list(shaped["notes"], "action_cues.notes", max_items=MAX_NOTES, category=_R) + for i, note in enumerate(notes): + npath = f"action_cues.notes[{i}]" + _obj(note, npath, frozenset({"handle", "text", "enforce"}), {"handle", "text", "enforce"}, _R) + _match(note["handle"], f"{npath}.handle", HANDLE_RE, category=_R) + text = _str(note["text"], f"{npath}.text", max_bytes=MAX_NOTE_TEXT_BYTES, min_bytes=1, + category=_R) + if "\n" in text: + _fail(_R, f"{npath}.text", "must be a single line") + _bool(note["enforce"], f"{npath}.enforce", _R) + _number(shaped["latency_ms"], "action_cues.latency_ms", category=_R) + return shaped + + +def validate_rules(resp: Any) -> dict[str, Any]: + """Validate ``GET /memory/rules``; every rule is enforceable by definition.""" + shaped = shape_response("rules", resp) + for key in RESPONSE_FIELDS["rules"]: + if key not in shaped: + _fail(_R, f"rules.{key}", "missing or wrong type") + _contract_version_field(shaped["contract_version"], "rules", _R) + _int(shaped["rules_version"], "rules.rules_version", minimum=0, category=_R) + rules = _list(shaped["rules"], "rules.rules", max_items=MAX_RULES, category=_R) + for i, rule in enumerate(rules): + rpath = f"rules.rules[{i}]" + _obj(rule, rpath, frozenset({"handle", "text", "action_classes", "artifact_keys", "enforce"}), + {"handle", "text", "action_classes", "artifact_keys", "enforce"}, _R) + _match(rule["handle"], f"{rpath}.handle", HANDLE_RE, category=_R) + text = _str(rule["text"], f"{rpath}.text", max_bytes=MAX_RULE_TEXT_BYTES, min_bytes=1, + category=_R) + if "\n" in text: + _fail(_R, f"{rpath}.text", "must be a single line") + classes = _list(rule["action_classes"], f"{rpath}.action_classes", + max_items=len(ACTION_CLASSES), category=_R) + for j, cls in enumerate(classes): + _enum(cls, f"{rpath}.action_classes[{j}]", ACTION_CLASSES, _R) + _artifact_keys(rule["artifact_keys"], f"{rpath}.artifact_keys", _R) + if rule["enforce"] is not True: + _fail(_R, f"{rpath}.enforce", "must be true") + return shaped + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +def fixture_path() -> Path: + return _FIXTURE_PATH + + +def load_fixtures() -> dict[str, Any]: + with _FIXTURE_PATH.open("rb") as handle: + return json.loads(handle.read().decode("utf-8")) + + +def fixture_sha256() -> str: + with _FIXTURE_PATH.open("rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +__all__ = [ + "ACTIONS", "ACTION_CLASSES", "ARTIFACT_KEY_KINDS", "BOUNDARIES", "CAPTURE_ORIGINS", + "CLIENT_ERROR_CATEGORIES", "CONSENT_DECISIONS", "CONTRACT_VERSION", "ContractError", + "DURABILITIES", "EMPTY_REASONS", "ERROR_CATEGORIES", "FIXTURE_SHA256", "HANDLE_RE", + "JOB_STATUSES", "KINDS", "LIMITS", "MEMORY_WRITE_SOURCES", "MESSAGE_ROLES", "NAMESPACE", + "PLUGIN_POSTABLE_KINDS", "RESPONSE_FIELDS", "SCHEMA_VERSION", "SPEAKER_ROLES", "UUID_RE", + "ack_ok", "canonical_bytes", "canonical_json", "deterministic_event_id", "fixture_path", + "fixture_sha256", "load_fixtures", "shape_response", "validate_action_cues", + "validate_action_cues_request", "validate_capabilities", "validate_envelope", + "validate_rules", "validate_turn_context", "validate_turn_context_request", +] diff --git a/plugins/substrate/contract/envelope-fixtures.json b/plugins/substrate/contract/envelope-fixtures.json new file mode 100644 index 0000000..8a1453b --- /dev/null +++ b/plugins/substrate/contract/envelope-fixtures.json @@ -0,0 +1,2072 @@ +{ + "description": "Substrate <-> Hermes plugin wire contract fixtures. Both validators (contract.py, contract.mjs) must accept every `valid` case, reject every `invalid` case with the listed error, and agree on `expected_event_id`.", + "contract_version": 1, + "schema_version": 3, + "namespace": "6f3a2b1c-9d8e-4f70-a1b2-c3d4e5f60718", + "actions": [ + "duplicate", + "queued", + "sealed", + "stored" + ], + "kinds": [ + "capture_session", + "capture_turn", + "consent", + "memory_forget", + "memory_write", + "page_propose", + "upload" + ], + "plugin_postable_kinds": [ + "capture_session", + "capture_turn", + "consent", + "memory_forget", + "memory_write" + ], + "action_classes": [ + "delegate", + "delete", + "deploy", + "execute", + "network", + "other", + "read", + "write" + ], + "error_categories": [ + "conflict", + "forbidden", + "internal", + "invalid_request", + "not_found", + "payload_too_large", + "rate_limited", + "unauthorized", + "unsupported_contract", + "unsupported_schema" + ], + "valid": [ + { + "name": "turn_with_tool", + "idempotency_key": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "group_chat_turn", + "idempotency_key": "7a1c9e2d-0b3f-4c5a-8d6e-2f1a0b9c8d7e", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "7a1c9e2d-0b3f-4c5a-8d6e-2f1a0b9c8d7e", + "kind": "capture_turn", + "session_id": "tg-supergroup-1001234567890-9b2e", + "offset": { + "start": 12, + "end": 14 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "tg:88213377", + "role": "participant", + "display": "Marcus" + }, + "created_at": "2026-09-02T11:02:14Z", + "payload": { + "turn_id": "turn-0007", + "messages": [ + { + "index": 12, + "role": "user", + "timestamp": "2026-09-02T11:02:10Z", + "speaker": { + "id": "tg:88213377", + "role": "participant", + "display": "Marcus" + }, + "content": "@hermes when is the Sitescope pilot review with Turner?" + }, + { + "index": 13, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "content": "The Sitescope pilot review with Turner is on Thursday 2026-09-04." + } + ] + } + } + }, + { + "name": "session_end", + "idempotency_key": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "session_switch", + "idempotency_key": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6", + "kind": "capture_session", + "session_id": "tg-supergroup-1001234567890-9b2e", + "offset": { + "start": 14, + "end": 14 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T11:30:00Z", + "payload": { + "boundary": "switch", + "session_complete": false, + "next_session_id": "tg-supergroup-1001234567890-c04f", + "message_high_water": 14, + "platform": "telegram", + "chat_type": "supergroup", + "participants": [ + { + "id": "tg:88213377", + "display": "Marcus" + }, + { + "id": "tg:4410021", + "display": "Pavel" + } + ] + } + } + }, + { + "name": "memory_write", + "idempotency_key": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "memory_write", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "text": "Hermes must never be exposed publicly; Tailscale only.", + "about": "hermes deployment", + "durability": "durable", + "source": "memory_remember" + } + } + }, + { + "name": "memory_forget", + "idempotency_key": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081", + "kind": "memory_forget", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:19:11Z", + "payload": { + "handle": "m:44a1b02e", + "reason": "User says the Tailscale-only restriction was lifted on 2026-08-30." + } + } + }, + { + "name": "consent", + "idempotency_key": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "kind": "consent", + "session_id": "onboarding:7c1f3a9e", + "offset": { + "start": 0, + "end": 0 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:14:02Z", + "payload": { + "version": 1, + "scope": "hermes_history", + "decision": "approved", + "recorded_at": "2026-09-02T10:14:02Z", + "includes_other_profiles": false + } + } + }, + { + "name": "replay_deterministic", + "idempotency_key": "5a3a451b-c18c-56cb-a6ac-caa5b92040df", + "expected_event_id": "5a3a451b-c18c-56cb-a6ac-caa5b92040df", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a3a451b-c18c-56cb-a6ac-caa5b92040df", + "kind": "capture_turn", + "session_id": "20260815T090000-cli-3e9a1c40", + "offset": { + "start": 0, + "end": 2 + }, + "capture_origin": "history_replay", + "batch_id": "9b1d4e2f7a3c5068", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:30:00Z", + "payload": { + "turn_id": "history:20260815T090000-cli-3e9a1c40:0", + "messages": [ + { + "index": 0, + "role": "user", + "timestamp": "2026-08-15T09:00:04Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Remind me what the Sitescope beachhead segment is." + }, + { + "index": 1, + "role": "assistant", + "timestamp": "2026-08-15T09:00:09Z", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "content": "Mid-sized US general contractors." + } + ] + } + } + } + ], + "invalid": [ + { + "name": "schema_2", + "error": "unsupported_schema", + "envelope": { + "schema_version": 2, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "schema_missing", + "error": "unsupported_schema", + "envelope": { + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "contract_0", + "error": "unsupported_contract", + "envelope": { + "schema_version": 3, + "contract_version": 0, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "contract_string", + "error": "unsupported_contract", + "envelope": { + "schema_version": 3, + "contract_version": "1", + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "memory_write", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "text": "Hermes must never be exposed publicly; Tailscale only.", + "about": "hermes deployment", + "durability": "durable", + "source": "memory_remember" + } + } + }, + { + "name": "unknown_kind", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "note", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "text": "Hermes must never be exposed publicly; Tailscale only.", + "about": "hermes deployment", + "durability": "durable", + "source": "memory_remember" + } + } + }, + { + "name": "kind_not_postable", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "page_propose", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "page_id": "pg_01", + "title": "Ops", + "prompt": "Summarize ops", + "session_id": "20260902T101500-cli-7c1f3a9e" + } + } + }, + { + "name": "event_id_not_uuid", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "evt-123", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "idempotency_mismatch", + "error": "invalid_request", + "idempotency_key": "00000000-0000-4000-8000-000000000000", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "tool_args_over_4k", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "command": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "tool_result_over_8k", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "offset_missing", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "offset_misaligned", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 9 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "unknown_top_level_field", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + }, + "note": "hi" + } + }, + { + "name": "speaker_role_invalid", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "system", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "created_at_not_rfc3339", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02 10:22:05", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "batch_id_not_hex", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a3a451b-c18c-56cb-a6ac-caa5b92040df", + "kind": "capture_turn", + "session_id": "20260815T090000-cli-3e9a1c40", + "offset": { + "start": 0, + "end": 2 + }, + "capture_origin": "history_replay", + "batch_id": "batch-1", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:30:00Z", + "payload": { + "turn_id": "history:20260815T090000-cli-3e9a1c40:0", + "messages": [ + { + "index": 0, + "role": "user", + "timestamp": "2026-08-15T09:00:04Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Remind me what the Sitescope beachhead segment is." + }, + { + "index": 1, + "role": "assistant", + "timestamp": "2026-08-15T09:00:09Z", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "content": "Mid-sized US general contractors." + } + ] + } + } + }, + { + "name": "capture_origin_unknown", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a3a451b-c18c-56cb-a6ac-caa5b92040df", + "kind": "capture_turn", + "session_id": "20260815T090000-cli-3e9a1c40", + "offset": { + "start": 0, + "end": 2 + }, + "capture_origin": "import", + "batch_id": "9b1d4e2f7a3c5068", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:30:00Z", + "payload": { + "turn_id": "history:20260815T090000-cli-3e9a1c40:0", + "messages": [ + { + "index": 0, + "role": "user", + "timestamp": "2026-08-15T09:00:04Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Remind me what the Sitescope beachhead segment is." + }, + { + "index": 1, + "role": "assistant", + "timestamp": "2026-08-15T09:00:09Z", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "content": "Mid-sized US general contractors." + } + ] + } + } + }, + { + "name": "message_role_system", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "system", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "tool_message_missing_digest", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "tool_calls_on_user", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000.", + "tool_calls": [] + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "args": { + "path": "/etc/nginx/sites-enabled/substrate.conf" + } + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "truncated_args_with_args", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "kind": "capture_turn", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 4, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:15:42.318000Z", + "payload": { + "turn_id": "turn-0003", + "messages": [ + { + "index": 4, + "role": "user", + "timestamp": "2026-09-02T10:15:30Z", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "content": "Check whether the nginx config on the box still proxies /api to port 3000." + }, + { + "index": 5, + "role": "assistant", + "content": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "tool_calls": [ + { + "id": "c1", + "tool_name": "terminal", + "args": { + "command": "ls" + }, + "args_truncated": true, + "args_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "args_preview": "ls" + } + ] + }, + { + "index": 6, + "role": "tool", + "tool_call_id": "call_01HZX3Q9K7W2", + "tool_name": "read_file", + "content": "server {\n listen 443 ssl;\n server_name app.trysubstrate.co;\n location /api {\n proxy_pass http://127.0.0.1:3000;\n }\n}\n", + "result_digest": "6f03d49e1fff6bdecdfe52976ca8a0701ecdc1047959b1fb5ae8a302b03fb42f", + "result_bytes": 124, + "result_truncated": false + }, + { + "index": 7, + "role": "assistant", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "timestamp": "2026-09-02T10:15:41Z", + "content": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ] + } + } + }, + { + "name": "payload_float", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8.5, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "memory_write_text_over_4k", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "memory_write", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "text": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "about": "hermes deployment", + "durability": "durable", + "source": "memory_remember" + } + } + }, + { + "name": "memory_write_bad_source", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "kind": "memory_write", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:18:00.5Z", + "payload": { + "text": "Hermes must never be exposed publicly; Tailscale only.", + "about": "hermes deployment", + "durability": "durable", + "source": "user" + } + } + }, + { + "name": "memory_forget_bad_handle", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081", + "kind": "memory_forget", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "hermes", + "role": "agent", + "display": "Hermes" + }, + "created_at": "2026-09-02T10:19:11Z", + "payload": { + "handle": "x:44a1b02e", + "reason": "User says the Tailscale-only restriction was lifted on 2026-08-30." + } + } + }, + { + "name": "consent_bad_decision", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "kind": "consent", + "session_id": "onboarding:7c1f3a9e", + "offset": { + "start": 0, + "end": 0 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:14:02Z", + "payload": { + "version": 1, + "scope": "hermes_history", + "decision": "maybe", + "recorded_at": "2026-09-02T10:14:02Z", + "includes_other_profiles": false + } + } + }, + { + "name": "consent_missing_profiles_flag", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "kind": "consent", + "session_id": "onboarding:7c1f3a9e", + "offset": { + "start": 0, + "end": 0 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:14:02Z", + "payload": { + "version": 1, + "scope": "hermes_history", + "decision": "approved", + "recorded_at": "2026-09-02T10:14:02Z" + } + } + }, + { + "name": "session_boundary_unknown", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "close", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + }, + { + "name": "session_payload_with_content", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "20260902T101500-cli-7c1f3a9e", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm", + "summary": "we talked about nginx" + } + } + }, + { + "name": "session_id_empty", + "error": "invalid_request", + "envelope": { + "schema_version": 3, + "contract_version": 1, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "kind": "capture_session", + "session_id": "", + "offset": { + "start": 8, + "end": 8 + }, + "capture_origin": "live", + "batch_id": "", + "speaker": { + "id": "owner", + "role": "owner", + "display": "Pavel" + }, + "created_at": "2026-09-02T10:22:05Z", + "payload": { + "boundary": "end", + "session_complete": true, + "message_high_water": 8, + "platform": "cli", + "chat_type": "dm" + } + } + } + ], + "ack": { + "valid": [ + { + "name": "stored", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "stored" + } + }, + { + "name": "duplicate", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "duplicate" + } + }, + { + "name": "sealed", + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "ack": { + "stored": true, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "action": "sealed" + } + }, + { + "name": "queued_with_job", + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "ack": { + "stored": true, + "event_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b", + "action": "queued", + "job_id": "job_01J6X0K9M3P2Q8R7S5T4V2W1X0" + } + } + ], + "reject": [ + { + "name": "stored_false", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": false, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "stored" + } + }, + { + "name": "stored_string", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": "true", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "stored" + } + }, + { + "name": "stored_missing", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "stored" + } + }, + { + "name": "event_id_mismatch", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "event_id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", + "action": "stored" + } + }, + { + "name": "event_id_missing", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "action": "stored" + } + }, + { + "name": "unknown_action", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "action": "accepted" + } + }, + { + "name": "action_missing", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "stored": true, + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b" + } + }, + { + "name": "legacy_v2_ack", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": { + "ok": true, + "id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b" + } + }, + { + "name": "empty_object", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": {} + }, + { + "name": "not_an_object", + "event_id": "3f2b8c1e-5a6d-4e7f-9a0b-1c2d3e4f5a6b", + "ack": "stored" + } + ] + }, + "requests": { + "turn_context": { + "contract_version": 1, + "session_id": "20260902T101500-cli-7c1f3a9e", + "turn_id": "turn-0004", + "turn": 4, + "platform": "cli", + "chat_type": "dm", + "sender_id": "owner", + "agent_identity": "hermes", + "agent_context": "primary", + "parent_session_id": "", + "message": "Can you draft the Turner pilot review agenda for Thursday?", + "recent_turns": [ + { + "user": "Check whether the nginx config on the box still proxies /api to port 3000.", + "assistant": "Yes. substrate.conf still proxies /api to 127.0.0.1:3000 behind TLS on 443." + } + ], + "injected_handles": [ + "m:e4f2a1c9", + "m:0b7d33aa" + ], + "cited_handles": [ + "m:0b7d33aa" + ], + "deadline_ms": 500 + }, + "action_cues": { + "contract_version": 1, + "session_id": "20260902T101500-cli-7c1f3a9e", + "turn_id": "turn-0004", + "tool_call_id": "call_01HZX3Q9K7W3", + "tool_name": "terminal", + "action_class": "execute", + "artifact_keys": [ + { + "kind": "host", + "key": "app.trysubstrate.co" + }, + { + "kind": "path", + "key": "/etc/nginx/sites-enabled/substrate.conf" + } + ], + "deadline_ms": 100 + } + }, + "responses": { + "capabilities": { + "contract_version": 1, + "provider": "substrate", + "server_commit": "b80e66b", + "limits": { + "max_event_bytes": 262144, + "max_upload_bytes": 262144, + "max_tool_call_bytes": 4096, + "max_tool_result_bytes": 8192, + "turn_context_deadline_ms": 500, + "action_cues_deadline_ms": 100, + "rules_refresh_seconds": 300 + }, + "actions": [ + "stored", + "duplicate", + "sealed", + "queued" + ], + "kinds": [ + "capture_turn", + "capture_session", + "memory_write", + "memory_forget", + "consent", + "page_propose", + "upload" + ], + "tenant": { + "tenant_id": "ten_01J6WZ3R8QK4M2N7P9S1T5V0X3", + "brief_version": 7 + } + }, + "turn_context": { + "contract_version": 1, + "session_id": "20260902T101500-cli-7c1f3a9e", + "turn": 4, + "block": "Memory (handles expand with memory_expand; evidence with memory_evidence):\n- Sitescope pilot review with Turner is due Thursday 2026-09-04. [m:0b7d33aa]\n- Last time you spoke with Marcus Hale he asked about the Procore integration. [m:e4f2a1c9]\n- Hermes must never be exposed publicly; Tailscale only. [m:44a1b02e]", + "handles": [ + "m:0b7d33aa", + "m:e4f2a1c9", + "m:44a1b02e" + ], + "tail_handles": [ + "m:0b7d33aa", + "m:e4f2a1c9" + ], + "brief_version": 7, + "latency_ms": 41, + "empty_reason": "" + }, + "action_cues": { + "contract_version": 1, + "tool_call_id": "call_01HZX3Q9K7W3", + "notes": [ + { + "handle": "m:44a1b02e", + "text": "Hermes must never be exposed publicly; Tailscale only.", + "enforce": true + } + ], + "latency_ms": 12 + }, + "rules": { + "contract_version": 1, + "rules_version": 3, + "rules": [ + { + "handle": "m:44a1b02e", + "text": "Hermes must never be exposed publicly; Tailscale only.", + "action_classes": [ + "deploy", + "network", + "execute" + ], + "artifact_keys": [ + { + "kind": "host", + "key": "app.trysubstrate.co" + }, + { + "kind": "path", + "key": "/etc/nginx" + } + ], + "enforce": true + } + ] + } + } +} diff --git a/plugins/substrate/plugin.py b/plugins/substrate/plugin.py new file mode 100644 index 0000000..8aaa431 --- /dev/null +++ b/plugins/substrate/plugin.py @@ -0,0 +1,704 @@ +"""Native Hermes retrieval plugin for Substrate. + +``register`` only registers callbacks. Configuration and network access are +resolved lazily when a callback actually runs. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import queue +import re +import threading +import uuid +from datetime import datetime, timezone +from typing import Any, Mapping + +from . import contract +from .client import ClientError, SubstrateClient + +STATIC_MEMORY_PROMPT = ( + "Substrate memory. Lines in `` are facts from the user's " + "knowledge base, selected for this turn. Use them naturally and do not " + "announce that you remembered. `[contested]` means sources disagree; call " + "`memory_expand` before relying on it. `[as of DATE]` means it may have " + "changed. Call `memory_evidence` when the user asks why you believe something. " + "Call `memory_search` with your intended action before irreversible operations. " + "Pinned pages follow." +) + +TOOLSET = "substrate" +_TOOL_RESULT_BYTES = contract.LIMITS["max_tool_result_bytes"] +_TEXT_SECRET_RE = re.compile( + r"(?i)(\b(?:authorization|api[_-]?key|access[_-]?token|token|password|secret)\b\s*[:=]\s*)" + r"(?:bearer\s+)?[^\s,;]+" +) +_SK_RE = re.compile(r"\bsk_[A-Za-z0-9_-]{8,}\b") +_SENSITIVE_KEY_RE = re.compile( + r"(?i)(?:authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key)" +) + + +def _clean_unicode(value: str) -> str: + return value.encode("utf-8", "replace").decode("utf-8") + + +def _clip_utf8(value: str, maximum: int) -> str: + value = _clean_unicode(value) + raw = value.encode("utf-8") + if len(raw) <= maximum: + return value + return raw[:maximum].decode("utf-8", "ignore") + + +def _redact_text(value: str) -> str: + value = _clean_unicode(value) + value = _TEXT_SECRET_RE.sub(r"\1[REDACTED]", value) + return _SK_RE.sub("[REDACTED]", value) + + +def _as_text(value: Any) -> str: + if isinstance(value, str): + return value + if value is None: + return "" + try: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + except Exception: + return str(value) + + +def _bounded_text(value: Any, maximum: int) -> str: + return _clip_utf8(_redact_text(_as_text(value)), maximum) + + +def _valid_handle(value: Any) -> bool: + return isinstance(value, str) and contract.HANDLE_RE.fullmatch(value) is not None + + +def _handles(value: Any) -> list[str]: + if not isinstance(value, (list, tuple)): + return [] + return [item for item in value[: contract.MAX_HANDLES] if _valid_handle(item)] + + +def _recent_turns(history: Any) -> list[dict[str, str]]: + """Extract the last two completed user/assistant pairs from Hermes history.""" + if not isinstance(history, list): + return [] + pairs: list[dict[str, str]] = [] + pending: str | None = None + answer: str | None = None + for item in history: + if not isinstance(item, Mapping): + continue + role = item.get("role") + if role == "user": + if pending is not None and answer is not None: + pairs.append({"user": pending, "assistant": answer}) + pending = _bounded_text(item.get("content", ""), contract.MAX_RECENT_TURN_BYTES) + answer = None + elif role == "assistant" and pending is not None: + # A tool-using turn can contain an assistant tool-call row followed + # by the final assistant row. Keep the last assistant content. + answer = _bounded_text(item.get("content", ""), contract.MAX_RECENT_TURN_BYTES) + if pending is not None and answer is not None: + pairs.append({"user": pending, "assistant": answer}) + return pairs[-contract.MAX_RECENT_TURNS :] + + +def _derive_turn(history: Any) -> int: + if not isinstance(history, list): + return 0 + # pre_llm_call receives history including the current, unanswered user row. + users = sum( + 1 for item in history if isinstance(item, Mapping) and item.get("role") == "user" + ) + last_role = next( + ( + item.get("role") + for item in reversed(history) + if isinstance(item, Mapping) and item.get("role") in {"user", "assistant", "tool"} + ), + None, + ) + return max(0, users - (1 if last_role == "user" else 0)) + + +def _turn_context_request( + session_id: Any, + user_message: Any, + conversation_history: Any, + **kwargs: Any, +) -> dict[str, Any]: + explicit_turn = kwargs.get("turn") + turn = ( + explicit_turn + if isinstance(explicit_turn, int) and not isinstance(explicit_turn, bool) and explicit_turn >= 0 + else _derive_turn(conversation_history) + ) + turn_id = _bounded_text(kwargs.get("turn_id", ""), contract.MAX_ID_BYTES) + if not turn_id: + # Current Hermes supplies turn_id. This fallback keeps older hosts valid. + turn_id = str(uuid.uuid4()) + request = { + "contract_version": contract.CONTRACT_VERSION, + "session_id": _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES), + "turn_id": turn_id, + "turn": min(turn, contract.MAX_SAFE_INTEGER), + "platform": _bounded_text(kwargs.get("platform", ""), contract.MAX_PLATFORM_BYTES), + "chat_type": _bounded_text(kwargs.get("chat_type", ""), contract.MAX_CHAT_TYPE_BYTES), + "sender_id": _bounded_text(kwargs.get("sender_id", ""), contract.MAX_SPEAKER_ID_BYTES), + "agent_identity": _bounded_text( + kwargs.get("agent_identity", kwargs.get("agent_id", kwargs.get("model", ""))), + contract.MAX_SPEAKER_ID_BYTES, + ), + "agent_context": _bounded_text( + kwargs.get("agent_context", kwargs.get("profile_name", "")), + contract.MAX_AGENT_CONTEXT_BYTES, + ), + "parent_session_id": _bounded_text( + kwargs.get("parent_session_id", ""), contract.MAX_SESSION_ID_BYTES + ), + "message": _bounded_text(user_message, contract.MAX_TURN_MESSAGE_BYTES), + "recent_turns": _recent_turns(conversation_history), + "injected_handles": _handles(kwargs.get("injected_handles", [])), + "cited_handles": _handles(kwargs.get("cited_handles", [])), + "deadline_ms": contract.LIMITS["turn_context_deadline_ms"], + } + return contract.validate_turn_context_request(request) + + +def pre_llm_call( + session_id: str = "", + user_message: str = "", + conversation_history: list[Any] | None = None, + **kwargs: Any, +) -> dict[str, str] | None: + """Fetch and validate per-turn memory. Every failure injects nothing.""" + try: + request = _turn_context_request( + session_id, user_message, conversation_history or [], **kwargs + ) + response = SubstrateClient.from_env().post_json( + "/api/v1/memory/turn-context", request, timeout=0.5 + ) + shaped = contract.shape_response("turn_context", response) + checked = contract.validate_turn_context(shaped) + # Bind the response to this request, rather than trusting valid data for + # another session/turn. + if checked["session_id"] != request["session_id"] or checked["turn"] != request["turn"]: + return None + block = checked["block"] + return {"context": block} if block else None + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Turn capture +# --------------------------------------------------------------------------- + + +def _safe_value(value: Any, *, depth: int = 0) -> Any: + """Redact and bound arbitrary tool arguments into canonical JSON values.""" + if depth >= 8: + return "[TRUNCATED]" + if value is None or isinstance(value, bool): + return value + if isinstance(value, int) and not isinstance(value, bool): + return value if abs(value) <= contract.MAX_SAFE_INTEGER else str(value) + if isinstance(value, float): + return str(value) if math.isfinite(value) else "[NONFINITE]" + if isinstance(value, str): + return _bounded_text(value, 8192) + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in list(value.items())[:128]: + name = _clip_utf8(str(key), 256) + result[name] = "[REDACTED]" if _SENSITIVE_KEY_RE.search(name) else _safe_value( + child, depth=depth + 1 + ) + return result + if isinstance(value, (list, tuple)): + return [_safe_value(item, depth=depth + 1) for item in value[:128]] + return _bounded_text(value, 1024) + + +def _tool_call(item: Any, ordinal: int) -> dict[str, Any] | None: + if not isinstance(item, Mapping): + return None + call_id = _bounded_text(item.get("id", f"call-{ordinal}"), contract.MAX_ID_BYTES) + function = item.get("function") if isinstance(item.get("function"), Mapping) else {} + name = _bounded_text( + item.get("tool_name", item.get("name", function.get("name", ""))), + contract.MAX_ID_BYTES, + ) + if not call_id or not name: + return None + raw_args = item.get("args", function.get("arguments", {})) + if isinstance(raw_args, str): + try: + raw_args = json.loads(raw_args) + except (json.JSONDecodeError, TypeError): + raw_args = {"value": raw_args} + if not isinstance(raw_args, Mapping): + raw_args = {"value": raw_args} + args = _safe_value(raw_args) + assert isinstance(args, dict) + encoded = contract.canonical_bytes(args) + if len(encoded) <= contract.LIMITS["max_tool_call_bytes"]: + return {"id": call_id, "tool_name": name, "args": args} + return { + "id": call_id, + "tool_name": name, + "args_truncated": True, + "args_sha256": hashlib.sha256(encoded).hexdigest(), + "args_preview": _clip_utf8(encoded.decode("utf-8"), contract.MAX_ARGS_PREVIEW_BYTES), + } + + +def _capture_message(item: Any, index: int) -> dict[str, Any] | None: + if not isinstance(item, Mapping): + return None + role = item.get("role") + if role not in contract.MESSAGE_ROLES: + return None + if role == "tool": + full = _redact_text(_as_text(item.get("content", ""))) + raw = full.encode("utf-8") + excerpt = _clip_utf8(full, contract.LIMITS["max_tool_result_bytes"]) + message: dict[str, Any] = { + "index": index, + "role": "tool", + "content": excerpt, + "result_digest": hashlib.sha256(raw).hexdigest(), + "result_bytes": min(len(raw), contract.MAX_SAFE_INTEGER), + } + if len(excerpt.encode("utf-8")) != len(raw): + message["result_truncated"] = True + call_id = _bounded_text( + item.get("tool_call_id", item.get("id", "")), contract.MAX_ID_BYTES + ) + name = _bounded_text(item.get("tool_name", item.get("name", "")), contract.MAX_ID_BYTES) + if call_id: + message["tool_call_id"] = call_id + if name: + message["tool_name"] = name + else: + message = { + "index": index, + "role": role, + "content": _bounded_text(item.get("content", ""), 32_768), + } + if role == "assistant" and isinstance(item.get("tool_calls"), list): + calls = [ + call + for n, raw_call in enumerate(item["tool_calls"][:32]) + if (call := _tool_call(raw_call, n)) is not None + ] + if calls: + message["tool_calls"] = calls + timestamp = item.get("timestamp") + if isinstance(timestamp, str) and contract.RFC3339_RE.fullmatch(timestamp): + message["timestamp"] = timestamp + return message + + +def _completed_messages( + history: Any, user_content: Any, assistant_content: Any +) -> list[dict[str, Any]]: + raw_history = history if isinstance(history, list) else [] + messages = [ + message + for index, item in enumerate(raw_history[-4096:]) + if (message := _capture_message(item, index)) is not None + ] + user = _bounded_text(user_content, 32_768) + assistant = _bounded_text(assistant_content, 32_768) + if not messages or messages[-1]["role"] != "assistant": + if user and (not messages or messages[-1]["role"] != "user"): + next_index = messages[-1]["index"] + 1 if messages else 0 + messages.append({"index": next_index, "role": "user", "content": user}) + if assistant: + next_index = messages[-1]["index"] + 1 if messages else 0 + messages.append({"index": next_index, "role": "assistant", "content": assistant}) + elif assistant: + # post_llm_call is after output transforms; capture what the user saw. + messages[-1]["content"] = assistant + return messages + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _capture_envelope( + user_content: Any, + assistant_content: Any, + *, + session_id: Any, + messages: Any, + **kwargs: Any, +) -> dict[str, Any] | None: + clean_messages = _completed_messages(messages, user_content, assistant_content) + if not clean_messages: + return None + event_id = str(uuid.uuid4()) + sid = _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES) + if not sid: + return None + turn_id = _bounded_text(kwargs.get("turn_id", ""), contract.MAX_ID_BYTES) or event_id + sender_id = _bounded_text(kwargs.get("sender_id", ""), contract.MAX_SPEAKER_ID_BYTES) or "user" + envelope: dict[str, Any] = { + "schema_version": contract.SCHEMA_VERSION, + "contract_version": contract.CONTRACT_VERSION, + "event_id": event_id, + "kind": "capture_turn", + "session_id": sid, + "offset": {"start": clean_messages[0]["index"], "end": clean_messages[-1]["index"] + 1}, + "capture_origin": "live", + "batch_id": "", + "speaker": {"id": sender_id, "role": "owner", "display": ""}, + "created_at": _utc_now(), + "payload": {"turn_id": turn_id, "messages": clean_messages}, + } + # The wire envelope is capped at 256 KiB. Retain the newest complete part + # of unusually large histories, with original message indices as offsets. + while len(contract.canonical_bytes(envelope)) > contract.LIMITS["max_event_bytes"] and len(clean_messages) > 1: + clean_messages.pop(0) + envelope["offset"]["start"] = clean_messages[0]["index"] + if len(contract.canonical_bytes(envelope)) > contract.LIMITS["max_event_bytes"]: + clean_messages[0]["content"] = _clip_utf8(clean_messages[0]["content"], 1024) + if clean_messages[0]["role"] == "tool": + # Digest/byte count still describe the bounded redacted source; only + # the posted excerpt is reduced further to fit the envelope. + clean_messages[0]["result_truncated"] = True + contract.validate_envelope(envelope, idempotency_key=event_id) + return envelope + + +class _CaptureWorker: + """A single lazy daemon worker. Queueing never waits for network I/O.""" + + def __init__(self) -> None: + self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=64) + self._lock = threading.Lock() + self._thread: threading.Thread | None = None + + def enqueue(self, envelope: dict[str, Any]) -> None: + with self._lock: + if self._thread is None: + self._thread = threading.Thread( + target=self._run, name="substrate-capture", daemon=True + ) + self._thread.start() + try: + self._queue.put_nowait(envelope) + except queue.Full: + pass + + def _run(self) -> None: + while True: + envelope = self._queue.get() + try: + SubstrateClient.from_env().post_json( + "/api/v1/ledger/events", + envelope, + timeout=5.0, + idempotency_key=envelope["event_id"], + max_response_bytes=65_536, + ) + except BaseException: + pass + finally: + self._queue.task_done() + + +_CAPTURE_WORKER = _CaptureWorker() + + +def sync_turn( + user_content: Any = "", + assistant_content: Any = "", + *, + session_id: str = "", + messages: list[Any] | None = None, + **kwargs: Any, +) -> None: + """Snapshot a completed Hermes turn and enqueue it for background capture.""" + try: + envelope = _capture_envelope( + user_content, + assistant_content, + session_id=session_id, + messages=messages or [], + **kwargs, + ) + if envelope is not None: + _CAPTURE_WORKER.enqueue(envelope) + except Exception: + pass + + +def post_llm_call( + session_id: str = "", + user_message: str = "", + assistant_response: str = "", + conversation_history: list[Any] | None = None, + **kwargs: Any, +) -> None: + """Adapt Hermes's successful-turn hook to the directly testable callback.""" + sync_turn( + user_message, + assistant_response, + session_id=session_id, + messages=conversation_history or [], + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Retrieval tools +# --------------------------------------------------------------------------- + + +def _error(category: str) -> str: + allowed = {"invalid_request", "invalid_response", "invalid_config", "timeout", "transport_error"} + return json.dumps( + {"error": category if category in allowed else "transport_error"}, + separators=(",", ":"), + ) + + +def _strict_args(args: Any, allowed: set[str], required: set[str]) -> dict[str, Any]: + if not isinstance(args, dict) or any(not isinstance(key, str) for key in args): + raise ValueError + if set(args) - allowed or not required.issubset(args): + raise ValueError + return args + + +def _limit(value: Any, default: int) -> int: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 20: + raise ValueError + return value + + +def _compact(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _fit_result(value: dict[str, Any]) -> str: + """Keep tool output valid JSON and below Hermes's captured-result bound.""" + text = _compact(value) + if len(text.encode("utf-8")) <= _TOOL_RESULT_BYTES: + return text + # Shorten bulky document fields before dropping discrete search/evidence + # items, which are usually more useful than the raw tail. + for key in ("markdown", "raw", "abstract", "text"): + child = value.get(key) + if isinstance(child, str) and len(_compact(value).encode("utf-8")) > _TOOL_RESULT_BYTES: + value[key] = _clip_utf8(child, max(0, _TOOL_RESULT_BYTES // 2)) + for key in ("results", "excerpts"): + child = value.get(key) + if isinstance(child, list): + while child and len(_compact(value).encode("utf-8")) > _TOOL_RESULT_BYTES: + child.pop() + text = _compact(value) + if len(text.encode("utf-8")) > _TOOL_RESULT_BYTES: + return _error("invalid_response") + return text + + +def _call_tool(route: str, path: str, request: dict[str, Any], shape: Any) -> str: + try: + response = SubstrateClient.from_env().post_json(path, request, timeout=3.0) + shaped = contract.shape_response(route, response) + return _fit_result(shape(shaped)) + except contract.ContractError: + return _error("invalid_response") + except ValueError: + return _error("invalid_request") + except ClientError as exc: + return _error(exc.category) + except Exception: + return _error("transport_error") + + +def _shape_search(value: dict[str, Any]) -> dict[str, Any]: + if value.get("contract_version") != contract.CONTRACT_VERSION or not isinstance(value.get("results"), list): + raise contract.ContractError("invalid_response") + results: list[dict[str, Any]] = [] + for item in value["results"][:20]: + if not isinstance(item, Mapping) or not _valid_handle(item.get("handle")): + continue + result: dict[str, Any] = {"handle": item["handle"]} + if isinstance(item.get("text"), str): + result["text"] = _bounded_text(item["text"], 4096) + score = item.get("score") + if isinstance(score, (int, float)) and not isinstance(score, bool) and math.isfinite(score): + result["score"] = score + if isinstance(item.get("kind"), str): + result["kind"] = _clip_utf8(item["kind"], 32) + if isinstance(item.get("markers"), list): + result["markers"] = [ + _bounded_text(marker, 128) for marker in item["markers"][:16] if isinstance(marker, str) + ] + results.append(result) + return {"contract_version": contract.CONTRACT_VERSION, "results": results} + + +def memory_search(args: dict[str, Any], **kwargs: Any) -> str: + try: + args = _strict_args(args, {"query", "kinds", "limit"}, {"query"}) + query = args["query"] + if not isinstance(query, str) or not query or len(query.encode("utf-8")) > contract.MAX_QUERY_BYTES: + raise ValueError + request: dict[str, Any] = {"query": query, "limit": _limit(args.get("limit"), 8)} + if "kinds" in args: + kinds = args["kinds"] + if not isinstance(kinds, list) or len(kinds) > contract.MAX_SEARCH_KINDS: + raise ValueError + if any(not isinstance(kind, str) or not kind or len(kind.encode("utf-8")) > 32 for kind in kinds): + raise ValueError + request["kinds"] = kinds + except Exception: + return _error("invalid_request") + return _call_tool("search", "/api/v1/memory/search", request, _shape_search) + + +def _shape_expand(value: dict[str, Any], expected: str) -> dict[str, Any]: + if value.get("contract_version") != contract.CONTRACT_VERSION or value.get("handle") != expected: + raise contract.ContractError("invalid_response") + if not isinstance(value.get("kind"), str): + raise contract.ContractError("invalid_response") + result: dict[str, Any] = { + "contract_version": contract.CONTRACT_VERSION, + "handle": expected, + "kind": _clip_utf8(value["kind"], 32), + } + for key, maximum in (("title", 200), ("abstract", 4096), ("markdown", contract.MAX_MARKDOWN_BYTES)): + if key in value: + if not isinstance(value[key], str): + raise contract.ContractError("invalid_response") + result[key] = _bounded_text(value[key], maximum) + return result + + +def memory_expand(args: dict[str, Any], **kwargs: Any) -> str: + try: + args = _strict_args(args, {"handle"}, {"handle"}) + handle = args["handle"] + if not _valid_handle(handle): + raise ValueError + except Exception: + return _error("invalid_request") + return _call_tool( + "expand", + "/api/v1/memory/expand", + {"handle": handle}, + lambda value: _shape_expand(value, handle), + ) + + +def _bounded_excerpt(value: Any) -> Any: + clean = _safe_value(value) + if isinstance(clean, dict): + return {str(key): child for key, child in list(clean.items())[:32]} + return clean + + +def _shape_evidence(value: dict[str, Any]) -> dict[str, Any]: + if value.get("contract_version") != contract.CONTRACT_VERSION or not isinstance(value.get("excerpts"), list): + raise contract.ContractError("invalid_response") + result: dict[str, Any] = { + "contract_version": contract.CONTRACT_VERSION, + "excerpts": [_bounded_excerpt(item) for item in value["excerpts"][:20]], + } + if "raw" in value: + if not isinstance(value["raw"], str): + raise contract.ContractError("invalid_response") + result["raw"] = _bounded_text(value["raw"], contract.MAX_MARKDOWN_BYTES) + return result + + +def memory_evidence(args: dict[str, Any], **kwargs: Any) -> str: + try: + args = _strict_args(args, {"handle", "raw", "limit"}, {"handle"}) + handle = args["handle"] + if not _valid_handle(handle): + raise ValueError + raw = args.get("raw", False) + if not isinstance(raw, bool): + raise ValueError + request = {"handle": handle, "raw": raw, "limit": _limit(args.get("limit"), 5)} + except Exception: + return _error("invalid_request") + return _call_tool("evidence", "/api/v1/memory/evidence", request, _shape_evidence) + + +MEMORY_SEARCH_SCHEMA = { + "name": "memory_search", + "description": "Search Substrate memory. Use the intended action in the query before irreversible operations.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "What to recall or the intended action."}, + "kinds": {"type": "array", "items": {"type": "string"}, "maxItems": 16}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 8}, + }, + "required": ["query"], + "additionalProperties": False, + }, +} +MEMORY_EXPAND_SCHEMA = { + "name": "memory_expand", + "description": "Expand a Substrate memory or page handle into its bounded detail.", + "parameters": { + "type": "object", + "properties": {"handle": {"type": "string", "pattern": contract.HANDLE_RE.pattern}}, + "required": ["handle"], + "additionalProperties": False, + }, +} +MEMORY_EVIDENCE_SCHEMA = { + "name": "memory_evidence", + "description": "Get evidence excerpts for a Substrate memory handle.", + "parameters": { + "type": "object", + "properties": { + "handle": {"type": "string", "pattern": contract.HANDLE_RE.pattern}, + "raw": {"type": "boolean", "default": False}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5}, + }, + "required": ["handle"], + "additionalProperties": False, + }, +} + + +def register(ctx: Any) -> None: + """Register the native Hermes surface without I/O or worker startup.""" + ctx.register_hook("pre_llm_call", pre_llm_call) + # Current Hermes post_llm_call includes the finalized full conversation in + # conversation_history, plus user_message and assistant_response. + ctx.register_hook("post_llm_call", post_llm_call) + ctx.register_tool( + name="memory_search", toolset=TOOLSET, schema=MEMORY_SEARCH_SCHEMA, handler=memory_search + ) + ctx.register_tool( + name="memory_expand", toolset=TOOLSET, schema=MEMORY_EXPAND_SCHEMA, handler=memory_expand + ) + ctx.register_tool( + name="memory_evidence", toolset=TOOLSET, schema=MEMORY_EVIDENCE_SCHEMA, handler=memory_evidence + ) + # This static section is frozen per session; dynamic memory stays in the + # supported pre_llm_call injection path. + ctx.register_system_prompt_section( + "substrate.memory", STATIC_MEMORY_PROMPT, position="after_memory", max_chars=2000 + ) diff --git a/plugins/substrate/plugin.yaml b/plugins/substrate/plugin.yaml new file mode 100644 index 0000000..152a9e9 --- /dev/null +++ b/plugins/substrate/plugin.yaml @@ -0,0 +1,15 @@ +name: substrate +version: 0.1.0 +description: Substrate retrieval and completed-turn capture for Hermes +provides_tools: + - memory_search + - memory_expand + - memory_evidence +provides_hooks: + - pre_llm_call + - post_llm_call +requires_env: + - name: SUBSTRATE_API_KEY + description: Substrate API key + url: https://app.trysubstrate.co + secret: true diff --git a/scripts/verify_public_plugin_candidate.py b/scripts/verify_public_plugin_candidate.py index 4d6c537..f24d78a 100644 --- a/scripts/verify_public_plugin_candidate.py +++ b/scripts/verify_public_plugin_candidate.py @@ -83,6 +83,7 @@ "c8eb7c5157ac7027dbf6ae86235e63dea6a2dd1faab46f0cbcbd536fbdd21ecf", "1cce63f45c357df4710e24a8351835c4382a47751d57edf4207e234eb350807b", "ac62cbf3799a35bbb239a0b951411491aab8ee0f2f2c33e66b8a3380a8c20e88", + "3638d8fc00fb6328b2235dd77c00d9ba937e9b4a13cadac17f15f514f5bb9561", } ), "scripts/benchmark_migration.py": frozenset( @@ -127,6 +128,10 @@ "e90c78ea6b182c558079c59752be07af1bc7937b69e06b66ee96a74bc8f7f0db", } ), + "plugins/substrate/README.md": frozenset({"35b846e7abdb4faad9382c015f6668d4c4bcbef1e5bb49b27b51368634cfd07c"}), + "plugins/substrate/client.py": frozenset({"a1b0718e6e9435d0b5530bd0baa8bdb0944c47cbdd4b74c5058267ac8eed33f8"}), + "plugins/substrate/contract.py": frozenset({"f8dabf7d1e8f0c8ce5b6f1dc3529ef5d751b6b98dec1f59705392ff099e24346"}), + "plugins/substrate/plugin.yaml": frozenset({"86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4"}), "tests/test_publication_scanner.py": frozenset( { "c2352cc593ab08d452479f9ed59651e95acdf0cd5511a94f3830e7910bf65a21", @@ -301,10 +306,10 @@ # only that sorted policy projection, so ordinary byte changes do not change # policy. Adding, moving, or reclassifying a file requires explicit review. TRUSTED_INVENTORY_POLICY_SHA256 = ( - "4b444b2583fbdd340b17d279fd169103c57f87a56dece39988d784b311222920" + "022c4bc0436f6b329d837944a52efd0842ba8b6ca71e7bece46efbb3560a49ae" ) TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = ( - "47091457ab7ae27771e7e4580938f0f0bead181a1b62683784d8a07d468f108f" + "2d626a34afe1d8d1655284be936433a46f31427e3d40ff5d355222443754edec" ) SCANNER_PATH = "scripts/verify_public_plugin_candidate.py" DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json" diff --git a/tests/test_retrieval_contract.py b/tests/test_retrieval_contract.py new file mode 100644 index 0000000..cf87c5a --- /dev/null +++ b/tests/test_retrieval_contract.py @@ -0,0 +1,230 @@ +"""Contract fixture round trip, digest literal, deterministic ids, ACK table.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import sys +import uuid +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins")) + +from substrate import contract as c + +FIXTURES = c.load_fixtures() + + +def _case(section: str, name: str) -> dict: + return next(case for case in FIXTURES[section] if case["name"] == name) + + +def test_fixture_digest_literal_matches_file() -> None: + assert len(c.FIXTURE_SHA256) == 64 and int(c.FIXTURE_SHA256, 16) >= 0 + assert c.fixture_sha256() == c.FIXTURE_SHA256 + + +def test_fixture_constants_agree_with_module() -> None: + assert FIXTURES["contract_version"] == c.CONTRACT_VERSION == 1 + assert FIXTURES["schema_version"] == c.SCHEMA_VERSION == 3 + assert FIXTURES["namespace"] == str(c.NAMESPACE) + assert FIXTURES["actions"] == sorted(c.ACTIONS) + assert FIXTURES["kinds"] == sorted(c.KINDS) + assert FIXTURES["plugin_postable_kinds"] == sorted(c.PLUGIN_POSTABLE_KINDS) + assert FIXTURES["action_classes"] == sorted(c.ACTION_CLASSES) + assert FIXTURES["error_categories"] == sorted(c.ERROR_CATEGORIES) + assert {"page_propose", "upload"} <= c.KINDS + assert not ({"page_propose", "upload"} & c.PLUGIN_POSTABLE_KINDS) + + +@pytest.mark.parametrize("case", FIXTURES["valid"], ids=lambda case: case["name"]) +def test_valid_envelopes_pass(case: dict) -> None: + c.validate_envelope(case["envelope"], idempotency_key=case["idempotency_key"]) + # Round trip through canonical bytes must not change validity. + again = json.loads(c.canonical_json(case["envelope"])) + c.validate_envelope(again, idempotency_key=case["idempotency_key"]) + + +def test_required_valid_cases_present() -> None: + names = {case["name"] for case in FIXTURES["valid"]} + assert {"turn_with_tool", "session_end", "memory_write", "memory_forget", "consent", + "replay_deterministic"} <= names + + +def test_turn_with_tool_shape() -> None: + envelope = _case("valid", "turn_with_tool")["envelope"] + messages = envelope["payload"]["messages"] + tool = next(m for m in messages if m["role"] == "tool") + assert tool["result_digest"] == hashlib.sha256(tool["content"].encode()).hexdigest() + assistant = next(m for m in messages if "tool_calls" in m) + assert assistant["tool_calls"][0]["id"] == tool["tool_call_id"] + assert len(c.canonical_bytes(assistant["tool_calls"][0]["args"])) <= c.LIMITS["max_tool_call_bytes"] + + +@pytest.mark.parametrize("case", FIXTURES["invalid"], ids=lambda case: case["name"]) +def test_invalid_envelopes_raise_listed_category(case: dict) -> None: + envelope = case["envelope"] + key = case.get("idempotency_key", envelope.get("event_id")) + with pytest.raises(c.ContractError) as info: + c.validate_envelope(envelope, idempotency_key=key if isinstance(key, str) else None) + assert info.value.category == case["error"], info.value.detail + assert info.value.category in c.ERROR_CATEGORIES + + +def test_validation_order_and_size() -> None: + with pytest.raises(c.ContractError, match="invalid_request"): + c.validate_envelope([]) + with pytest.raises(c.ContractError) as info: + c.validate_envelope({"schema_version": 2, "contract_version": 0}) + assert info.value.category == "unsupported_schema" + with pytest.raises(c.ContractError) as info: + c.validate_envelope({"schema_version": 3, "contract_version": 0}) + assert info.value.category == "unsupported_contract" + with pytest.raises(c.ContractError) as info: + c.validate_envelope({"schema_version": 3, "contract_version": True}) + assert info.value.category == "unsupported_contract" + with pytest.raises(c.ContractError) as info: + c.validate_envelope({"schema_version": 3, "contract_version": 1}) + assert info.value.category == "invalid_request" + envelope = copy.deepcopy(_case("valid", "session_end")["envelope"]) + envelope["payload"]["platform"] = "x" * c.LIMITS["max_event_bytes"] + with pytest.raises(c.ContractError) as info: + c.validate_envelope(envelope) + assert info.value.category == "payload_too_large" + + +def test_server_written_kinds_need_explicit_allow() -> None: + envelope = _case("invalid", "kind_not_postable")["envelope"] + with pytest.raises(c.ContractError): + c.validate_envelope(envelope) + c.validate_envelope(envelope, allowed_kinds=c.KINDS) + + +def test_size_limits_enforced() -> None: + base = copy.deepcopy(_case("valid", "turn_with_tool")["envelope"]) + call = base["payload"]["messages"][1]["tool_calls"][0] + call["args"] = {"command": "x" * 4000} # canonical {"command":"xxx"} = 4014 bytes + c.validate_envelope(base) + call["args"] = {"command": "x" * 4090} + with pytest.raises(c.ContractError): + c.validate_envelope(base) + truncated = {"id": call["id"], "tool_name": "terminal", "args_truncated": True, + "args_sha256": "a" * 64, "args_preview": "x" * 1024} + base["payload"]["messages"][1]["tool_calls"][0] = truncated + c.validate_envelope(base) + truncated["args_preview"] = "x" * 1025 + with pytest.raises(c.ContractError): + c.validate_envelope(base) + truncated["args_preview"] = "x" + tool = base["payload"]["messages"][2] + tool["content"] = "é" * 4096 # 8192 bytes, ok + c.validate_envelope(base) + tool["content"] = "é" * 4097 + with pytest.raises(c.ContractError): + c.validate_envelope(base) + + +def test_deterministic_event_id_vector() -> None: + case = _case("valid", "replay_deterministic") + env = case["envelope"] + got = c.deterministic_event_id(env["kind"], env["session_id"], env["offset"], env["payload"]) + assert got == case["expected_event_id"] == env["event_id"] + assert uuid.UUID(got).version == 5 + # Re-keying the same object in a different insertion order changes nothing. + reordered = json.loads(json.dumps(env["payload"])) + assert c.deterministic_event_id(env["kind"], env["session_id"], env["offset"], reordered) == got + # Any change to the payload changes the id. + changed = copy.deepcopy(env["payload"]) + changed["messages"][0]["content"] += "!" + assert c.deterministic_event_id(env["kind"], env["session_id"], env["offset"], changed) != got + assert c.deterministic_event_id(env["kind"], env["session_id"], {"start": 1, "end": 3}, env["payload"]) != got + + +def test_canonical_json_is_stable() -> None: + value = {"b": 1, "a": {"d": [3, {"z": 1, "y": 2}], "c": "é\n\x1f"}} + assert c.canonical_json(value) == '{"a":{"c":"é\\n\\u001f","d":[3,{"y":2,"z":1}]},"b":1}' + assert c.canonical_json({"é": 1, "z": 2, "\U0001F600": 3, "fi": 4}) == '{"z":2,"é":1,"fi":4,"😀":3}' + + +def test_ack_table() -> None: + for case in FIXTURES["ack"]["valid"]: + assert c.ack_ok(case["ack"], case["event_id"]), case["name"] + for case in FIXTURES["ack"]["reject"]: + assert not c.ack_ok(case["ack"], case["event_id"]), case["name"] + assert not c.ack_ok(None, "x") + assert not c.ack_ok({"stored": 1, "event_id": "x", "action": "stored"}, "x") + + +def test_fixture_requests_and_responses_validate() -> None: + c.validate_turn_context_request(FIXTURES["requests"]["turn_context"]) + c.validate_action_cues_request(FIXTURES["requests"]["action_cues"]) + caps = c.validate_capabilities(FIXTURES["responses"]["capabilities"]) + assert caps["tenant"]["brief_version"] == 7 + ctx = c.validate_turn_context(FIXTURES["responses"]["turn_context"]) + assert ctx["handles"] and ctx["empty_reason"] == "" + cues = c.validate_action_cues(FIXTURES["responses"]["action_cues"]) + assert len(cues["notes"]) == 1 + rules = c.validate_rules(FIXTURES["responses"]["rules"]) + assert rules["rules"][0]["enforce"] is True + + +def test_response_validators_reject_drift() -> None: + tc = FIXTURES["responses"]["turn_context"] + for patch in ({"block": "x" * 8193}, {"block": "\n".join(["- l"] * 41)}, {"empty_reason": "later"}, + {"handles": ["x:1"]}, {"contract_version": 2}, {"latency_ms": "fast"}): + with pytest.raises(c.ContractError) as info: + c.validate_turn_context({**tc, **patch}) + assert info.value.category == "invalid_response", patch + ac = FIXTURES["responses"]["action_cues"] + with pytest.raises(c.ContractError): + c.validate_action_cues({**ac, "notes": [ac["notes"][0]] * 4}) + with pytest.raises(c.ContractError): + c.validate_action_cues({**ac, "notes": [{**ac["notes"][0], "text": "t" * 161}]}) + rules = FIXTURES["responses"]["rules"] + with pytest.raises(c.ContractError): + c.validate_rules({**rules, "rules": [{**rules["rules"][0], "enforce": False}]}) + caps = FIXTURES["responses"]["capabilities"] + with pytest.raises(c.ContractError) as info: + c.validate_capabilities({**caps, "contract_version": 2}) + assert info.value.category == "unsupported_contract" + with pytest.raises(c.ContractError): + c.validate_capabilities({**caps, "actions": ["stored"]}) + # Unknown top-level response fields are dropped by shaping, not rejected. + shaped = c.shape_response("turn_context", {**tc, "debug": {"x": 1}, "turn": "4"}) + assert "debug" not in shaped and "turn" not in shaped + + +def test_request_validators_reject_drift() -> None: + tc = FIXTURES["requests"]["turn_context"] + with pytest.raises(c.ContractError) as info: + c.validate_turn_context_request({**tc, "contract_version": 2}) + assert info.value.category == "unsupported_contract" + for patch in ({"message": "m" * 16385}, {"injected_handles": ["x:1"]}, {"extra": 1}, + {"recent_turns": [{"user": "a", "assistant": "b"}] * 3}, {"deadline_ms": 0}): + with pytest.raises(c.ContractError) as info: + c.validate_turn_context_request({**tc, **patch}) + assert info.value.category == "invalid_request", patch + ac = FIXTURES["requests"]["action_cues"] + for patch in ({"action_class": "run"}, {"artifact_keys": [{"kind": "path", "key": "/x"}] * 33}, + {"artifact_keys": [{"kind": "file", "key": "/x"}]}): + with pytest.raises(c.ContractError): + c.validate_action_cues_request({**ac, **patch}) + + +def test_response_fields_cover_every_route() -> None: + assert set(c.RESPONSE_FIELDS) == { + "capabilities", "events", "turn_context", "action_cues", "rules", "search", "expand", + "evidence", "propose", "pinned", "upload", "job_status", "import_status", + } + for fields in c.RESPONSE_FIELDS.values(): + for types in fields.values(): + assert isinstance(types, tuple) and all(isinstance(t, type) for t in types) + + +def test_handle_regex() -> None: + assert c.HANDLE_RE.match("m:44a1b02e") and c.HANDLE_RE.match("p:" + "f" * 64) + for bad in ("m:44A1B02E", "m:1234567", "x:44a1b02e", "m:" + "f" * 65, "44a1b02e", ""): + assert not c.HANDLE_RE.match(bad), bad diff --git a/tests/test_retrieval_plugin.py b/tests/test_retrieval_plugin.py new file mode 100644 index 0000000..b17f0dc --- /dev/null +++ b/tests/test_retrieval_plugin.py @@ -0,0 +1,299 @@ +"""Behavior tests for the thin native Hermes plugin.""" + +from __future__ import annotations + +import json +import sys +import threading +from pathlib import Path +import time +import urllib.request + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins")) + +from substrate import contract +from substrate import plugin + + +class Response: + def __init__(self, value, status=200): + self.raw = json.dumps(value).encode() + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size=-1): + return self.raw if size < 0 else self.raw[:size] + + +def test_pre_llm_call_posts_exact_contract_request(monkeypatch): + seen = {} + + def urlopen(request, timeout): + seen["url"] = request.full_url + seen["method"] = request.method + seen["body"] = json.loads(request.data) + seen["timeout"] = timeout + seen["auth"] = request.get_header("Authorization") + return Response( + { + "contract_version": 1, + "session_id": "session-1", + "turn": 1, + "block": "\n- Keep it private. [m:44a1b02e]\n", + "handles": ["m:44a1b02e"], + "tail_handles": [], + "brief_version": 2, + "latency_ms": 12.5, + "empty_reason": "", + "ignored_backend_debug": "drop me", + } + ) + + monkeypatch.setenv("SUBSTRATE_API_URL", "https://memory.example/") + monkeypatch.setenv("SUBSTRATE_API_KEY", "secret") + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + history = [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "current question"}, + ] + result = plugin.pre_llm_call( + "session-1", + "current question", + history, + turn_id="turn-2", + platform="cli", + chat_type="direct", + sender_id="person-1", + agent_identity="Hermes", + agent_context="default", + parent_session_id="parent-1", + injected_handles=["m:12345678"], + cited_handles=["p:abcdef12"], + ) + assert result == {"context": "\n- Keep it private. [m:44a1b02e]\n"} + assert seen == { + "url": "https://memory.example/api/v1/memory/turn-context", + "method": "POST", + "body": { + "contract_version": 1, + "session_id": "session-1", + "turn_id": "turn-2", + "turn": 1, + "platform": "cli", + "chat_type": "direct", + "sender_id": "person-1", + "agent_identity": "Hermes", + "agent_context": "default", + "parent_session_id": "parent-1", + "message": "current question", + "recent_turns": [{"user": "old question", "assistant": "old answer"}], + "injected_handles": ["m:12345678"], + "cited_handles": ["p:abcdef12"], + "deadline_ms": 500, + }, + "timeout": 0.5, + "auth": "Bearer secret", + } + + +def test_pre_llm_rejects_validator_failure_and_every_error(monkeypatch): + valid = { + "contract_version": 1, + "session_id": "s", + "turn": 0, + "block": "unsafe", + "handles": [], + "tail_handles": [], + "brief_version": 0, + "latency_ms": 1, + "empty_reason": "", + } + monkeypatch.setenv("SUBSTRATE_API_KEY", "k") + monkeypatch.setattr(plugin.SubstrateClient, "post_json", lambda *a, **k: valid) + monkeypatch.setattr( + contract, + "validate_turn_context", + lambda value: (_ for _ in ()).throw(contract.ContractError("invalid_response")), + ) + assert plugin.pre_llm_call("s", "q", [], turn_id="t") is None + + monkeypatch.setattr( + plugin.SubstrateClient, + "post_json", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("backend detail must not escape")), + ) + assert plugin.pre_llm_call("s", "q", [], turn_id="t") is None + + +def test_capture_envelope_has_full_completed_messages_and_validates(): + history = [ + {"role": "user", "content": "first"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call-1", "function": {"name": "terminal", "arguments": '{"token":"hide"}'}} + ], + }, + {"role": "tool", "tool_call_id": "call-1", "name": "terminal", "content": "token=hide"}, + {"role": "assistant", "content": "old visible answer"}, + ] + envelope = plugin._capture_envelope( + "first", + "final visible answer", + session_id="session", + messages=history, + turn_id="turn", + sender_id="owner", + ) + assert envelope is not None + messages = envelope["payload"]["messages"] + assert [row["role"] for row in messages] == ["user", "assistant", "tool", "assistant"] + assert messages[-1]["content"] == "final visible answer" + assert messages[1]["tool_calls"][0]["args"]["token"] == "[REDACTED]" + assert "hide" not in messages[2]["content"] + contract.validate_envelope(envelope, idempotency_key=envelope["event_id"]) + + +def test_sync_turn_is_nonblocking_and_uses_one_daemon_worker(monkeypatch): + started = threading.Event() + release = threading.Event() + captured = [] + + def blocked_post(self, path, body, **kwargs): + captured.append((path, body, kwargs)) + started.set() + release.wait(1) + return {} + + worker = plugin._CaptureWorker() + monkeypatch.setattr(plugin, "_CAPTURE_WORKER", worker) + monkeypatch.setattr(plugin.SubstrateClient, "post_json", blocked_post) + before = time.monotonic() + plugin.sync_turn( + "hello", + "world", + session_id="s", + messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}], + turn_id="t", + ) + assert time.monotonic() - before < 0.1 + assert started.wait(0.5) + assert worker._thread is not None and worker._thread.daemon + first_thread = worker._thread + plugin.sync_turn("again", "done", session_id="s", messages=[], turn_id="t2") + assert worker._thread is first_thread + release.set() + assert captured[0][0] == "/api/v1/ledger/events" + assert captured[0][2]["idempotency_key"] == captured[0][1]["event_id"] + + +def test_three_tools_validate_defaults_shape_and_bound(monkeypatch): + calls = [] + + def post(self, path, body, **kwargs): + calls.append((path, body)) + if path.endswith("search"): + return { + "contract_version": 1, + "results": [ + {"handle": "m:12345678", "text": "fact", "score": 0.8, "kind": "fact", "markers": []}, + {"handle": "bad", "text": "drop"}, + ], + "debug": "drop", + } + if path.endswith("expand"): + return { + "contract_version": 1, + "handle": "p:abcdef12", + "kind": "page", + "title": "Page", + "abstract": "Summary", + "markdown": "body", + "debug": "drop", + } + return {"contract_version": 1, "excerpts": [{"text": "evidence"}], "raw": "x" * 100_000} + + monkeypatch.setenv("SUBSTRATE_API_KEY", "k") + monkeypatch.setattr(plugin.SubstrateClient, "post_json", post) + search = json.loads(plugin.memory_search({"query": "planned delete"})) + expand = json.loads(plugin.memory_expand({"handle": "p:abcdef12"})) + evidence_text = plugin.memory_evidence({"handle": "m:12345678"}) + evidence = json.loads(evidence_text) + assert search == { + "contract_version": 1, + "results": [{"handle": "m:12345678", "kind": "fact", "markers": [], "score": 0.8, "text": "fact"}], + } + assert expand["markdown"] == "body" and "debug" not in expand + assert evidence["excerpts"] == [{"text": "evidence"}] + assert len(evidence_text.encode()) <= contract.LIMITS["max_tool_result_bytes"] + assert calls == [ + ("/api/v1/memory/search", {"query": "planned delete", "limit": 8}), + ("/api/v1/memory/expand", {"handle": "p:abcdef12"}), + ("/api/v1/memory/evidence", {"handle": "m:12345678", "raw": False, "limit": 5}), + ] + + +@pytest.mark.parametrize( + "callback,args", + [ + (plugin.memory_search, {"query": "", "limit": 8}), + (plugin.memory_search, {"query": "q", "limit": 21}), + (plugin.memory_expand, {"handle": "M:12345678"}), + (plugin.memory_evidence, {"handle": "m:12345678", "raw": "yes"}), + ], +) +def test_tools_fail_closed_on_invalid_input(callback, args): + assert json.loads(callback(args)) == {"error": "invalid_request"} + + +def test_tool_backend_failure_has_no_free_detail(monkeypatch): + monkeypatch.setenv("SUBSTRATE_API_KEY", "k") + monkeypatch.setattr( + plugin.SubstrateClient, + "post_json", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("secret backend stack")), + ) + result = plugin.memory_search({"query": "q"}) + assert json.loads(result) == {"error": "transport_error"} + assert "secret" not in result and "stack" not in result + + +def test_register_matches_native_hermes_context_and_has_no_side_effect(monkeypatch): + class Context: + def __init__(self): + self.hooks = {} + self.tools = {} + self.prompts = {} + + def register_hook(self, name, callback): + self.hooks[name] = callback + + def register_tool(self, *, name, toolset, schema, handler, **kwargs): + self.tools[name] = {"toolset": toolset, "schema": schema, "handler": handler} + + def register_system_prompt_section(self, id, content, *, position, max_chars): + self.prompts[id] = (content, position, max_chars) + + monkeypatch.setattr( + urllib.request, + "urlopen", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("register performed network I/O")), + ) + ctx = Context() + thread_before = plugin._CAPTURE_WORKER._thread + plugin.register(ctx) + assert set(ctx.hooks) == {"pre_llm_call", "post_llm_call"} + assert set(ctx.tools) == {"memory_search", "memory_expand", "memory_evidence"} + assert ctx.prompts == { + "substrate.memory": (plugin.STATIC_MEMORY_PROMPT, "after_memory", 2000) + } + assert plugin._CAPTURE_WORKER._thread is thread_before