Bläddra i källkod

Document the read tools where every reader of them looks first

The capability matrix still named three tools, the bundled skill's map did
too, and the platform hint — the only place telling the model the plugin
can read rather than only post — never mentioned reading at all. AGENTS.md
had it worse: its behavioural contract said the plugin tools obey the
mention lists, which two of the five deliberately do not. Left as it was,
the next agent to touch tool gating would read a rule that is no longer
true and treat the read tools as a bug.

The contract now says which tools the lists bind and why reading is exempt
(reading grants no privilege the bot's token lacked; the server's
visibility rules are the gate), and the shared-helper section lists
_resolve_thread_root next to _room_answers_in and _deliver_chunks, because
a rule that only lives in a diff gets re-broken by the next tool.
Paul Klumpp 1 vecka sedan
förälder
incheckning
1544a5c7b6
3 ändrade filer med 20 tillägg och 11 borttagningar
  1. 11 3
      AGENTS.md
  2. 3 3
      README.md
  3. 6 5
      skills/chatto-platform-plugin/SKILL.md

+ 11 - 3
AGENTS.md

@@ -222,10 +222,13 @@ changes must keep them, or consciously renegotiate the docs.**
   the web frontend's extraction. Broadcast and role targets arrive
   the web frontend's extraction. Broadcast and role targets arrive
   unexpanded, so neither path needs a roster to recognise them. DMs answer
   unexpanded, so neither path needs a roster to recognise them. DMs answer
   regardless so `/join` stays reachable;
   regardless so `/join` stays reachable;
-  `hermes_validate_config` rejects listing a room on both lists. The plugin
+  `hermes_validate_config` rejects listing a room on both lists. The sending
   tools obey the same lists: `chatto_send_room` refuses a channel on neither
   tools obey the same lists: `chatto_send_room` refuses a channel on neither
   (a DM room is never gated), because a tool that posted there would break the
   (a DM room is never gated), because a tool that posted there would break the
-  read-only promise the same paragraph makes.
+  read-only promise the same paragraph makes. The read tools are deliberately
+  exempt — `chatto_get_message` and `chatto_get_history` post nothing and
+  answer nothing, so reading a room grants no privilege the bot's token did not
+  already have and the server's own visibility rules are the only gate.
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
   context-less cron/notification delivery, not an answer destination.
   context-less cron/notification delivery, not an answer destination.
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
@@ -516,7 +519,7 @@ months).
 
 
 **The two send paths, one implementation.** A user cannot tell whether the agent
 **The two send paths, one implementation.** A user cannot tell whether the agent
 replied on its own or through a tool, so the two must not behave differently.
 replied on its own or through a tool, so the two must not behave differently.
-Two rules already exist once each, and each answers for both callers:
+Three rules already exist once each, and each answers for both callers:
 
 
 - **`_room_answers_in`** — whether the bot takes part in a room. It is both the
 - **`_room_answers_in`** — whether the bot takes part in a room. It is both the
   *inbound* gate (`ChattoAdapter._answers_in_room`) and the *outbound* one
   *inbound* gate (`ChattoAdapter._answers_in_room`) and the *outbound* one
@@ -532,6 +535,11 @@ Two rules already exist once each, and each answers for both callers:
   contracts genuinely differ; the *sending* is the same job and must not be.
   contracts genuinely differ; the *sending* is the same job and must not be.
   `on_posted` is how the adapter marks its own output seen — without it the
   `on_posted` is how the adapter marks its own output seen — without it the
   realtime echo of its own message would come back as input.
   realtime echo of its own message would come back as input.
+- **`_resolve_thread_root`** — "this event ID is a root of a thread in *this*
+  room", which both tools taking a `thread_root_event_id` need
+  (`_send_room` posting into one, `_get_history` reading one). Two copies
+  would drift into two tools disagreeing about the same event ID, which is
+  exactly the failure the shared helper exists to prevent.
 
 
 The standalone cron sender keeps its own copy on purpose (out of process, no
 The standalone cron sender keeps its own copy on purpose (out of process, no
 adapter — see *Why `vendor/` exists*), so agreement there is on the shared
 adapter — see *Why `vendor/` exists*), so agreement there is on the shared

+ 3 - 3
README.md

@@ -43,7 +43,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | file attachments | yes (chunked upload) |
 | file attachments | yes (chunked upload) |
 | image / video / audio / documents | yes (native attachments) |
 | image / video / audio / documents | yes (native attachments) |
 | DM participation | only once the person has DMed the bot — a bot cannot start a DM on Chatto, see [DM Initiation](#dm-initiation) |
 | DM participation | only once the person has DMed the bot — a bot cannot start a DM on Chatto, see [DM Initiation](#dm-initiation) |
-| plugin tools | yes (`chatto_list_rooms`, `chatto_send_room`, `chatto_send_dm` — see [Plugin tools](#plugin-tools)) |
+| plugin tools | yes (`chatto_list_rooms`, `chatto_send_room`, `chatto_send_dm`, `chatto_get_message`, `chatto_get_history` — see [Plugin tools](#plugin-tools)) |
 | room creation | yes |
 | room creation | yes |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
 | member directory | yes (cached) |
@@ -386,8 +386,8 @@ Beyond answering inbound messages, the agent can act on Chatto on its own initia
 | `chatto_list_rooms` | Lists the rooms visible to the bot with their IDs, kinds and membership. Optional `name` filters case-insensitively by substring. Useful for turning a channel name into the `platform:chat_id` address a cron `deliver` target needs. |
 | `chatto_list_rooms` | Lists the rooms visible to the bot with their IDs, kinds and membership. Optional `name` filters case-insensitively by substring. Useful for turning a channel name into the `platform:chat_id` address a cron `deliver` target needs. |
 | `chatto_send_room` | Posts a message into a channel or DM room, by `room_id` or `room_name`. Can post inside an existing thread (`thread_root_event_id`) or open a new one (`create_thread`). Either way the bot follows the thread afterwards, so later replies in it reach the agent. |
 | `chatto_send_room` | Posts a message into a channel or DM room, by `room_id` or `room_name`. Can post inside an existing thread (`thread_root_event_id`) or open a new one (`create_thread`). Either way the bot follows the thread afterwards, so later replies in it reach the agent. |
 | `chatto_send_dm` | Posts a message into the DM room you already have with a person, addressed by `user_id` or `user_login`. |
 | `chatto_send_dm` | Posts a message into the DM room you already have with a person, addressed by `user_id` or `user_login`. |
-| `chatto_get_message` | Reads one message (or several, via `event_ids`) with all its data: body, author, timestamps, reply and thread references, attachments, reactions, pinned and deleted status. `follow_reply_chain` (0-10) additionally resolves the messages it replies to, oldest-first — use it to resolve a `reply_to=` reference. |
-| `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, up to `limit` messages (1-100, default 20). `has_older` says whether older history exists. |
+| `chatto_get_message` | Reads one message (or several, via `event_ids`, answered in the order you list them) with all its data: body, author, timestamps, reply and thread references, attachments, reactions, pinned and deleted status. `follow_reply_chain` (0-10) additionally resolves the messages that message replies to, oldest-first — use it to resolve a `reply_to=` reference. It works with a single `event_id` only, because a chain belongs to one message. |
+| `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, in a page of up to `limit` messages (1-100, default 20). `has_older` says whether older history exists. |
 
 
 Four boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 Four boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 
 

+ 6 - 5
skills/chatto-platform-plugin/SKILL.md

@@ -13,7 +13,7 @@ metadata:
 
 
 ## When to Use
 ## When to Use
 
 
-Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
+Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`, `chatto_get_message`, `chatto_get_history`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
 
 
 ## The plugin tools and when to use them
 ## The plugin tools and when to use them
 
 
@@ -36,15 +36,15 @@ Resolves the room by ID or case-insensitively by name, then posts. A name shared
 
 
 ### chatto_get_message(room_id|room_name, event_id|event_ids, follow_reply_chain?) — read messages by ID
 ### chatto_get_message(room_id|room_name, event_id|event_ids, follow_reply_chain?) — read messages by ID
 
 
-Resolves the room (same `_resolve_room` as the send tools) and fetches the message(s) via `client.batch_get_messages` / `get_message`. Returns every field: body, author_user_id + author_login (hydrated via `batch_get_room_members`, best-effort — an unreadable directory costs the names, not the messages), created/updated/deleted timestamps, `in_reply_to`, `thread_root_event_id`, attachments, reactions, pinned, link preview, thread summary. Use it whenever a `reply_to=` / `in_reply_to` reference or any event ID appears in a message or session metadata.
+Resolves the room (same `_resolve_room` as the send tools) and fetches the message(s) — `client.get_message` for a single `event_id`, `client.batch_get_messages` for `event_ids`. Returns every field: body, author_user_id + author_login (hydrated via `batch_get_room_members`, best-effort — an unreadable directory costs the names, not the messages), created/updated/deleted timestamps, `in_reply_to`, `thread_root_event_id`, attachments (with a `video` block carrying duration, thumbnail and transcoded variants when the server sent one), reactions, pinned, link preview, thread summary. Use it whenever a `reply_to=` / `in_reply_to` reference or any event ID appears in a message or session metadata.
 
 
-- `follow_reply_chain` (0-10): walks the `in_reply_to` chain backwards (one RPC per hop) and returns the ancestors oldest-first in `reply_chain`.
-- `event_ids[]`: batch fetch; unknown IDs come back in `missing_event_ids` instead of failing the whole call. A single unknown `event_id` refuses cleanly.
+- `follow_reply_chain` (0-10): walks the `in_reply_to` chain backwards (`client.get_message` per hop) and returns the ancestors oldest-first in `reply_chain`. A single `event_id` only — a chain belongs to one message, so the tool refuses the combination rather than picking one. A hop the server cannot resolve ends the chain.
+- `event_ids[]`: batch fetch, answered in the order asked for; unknown IDs come back in `missing_event_ids` instead of failing the whole call. A single unknown `event_id` refuses cleanly.
 - **Not gated on the mention lists**: reading posts nothing and answers nothing, so any room the server lets the bot see is readable — the server's visibility rules are the only gate.
 - **Not gated on the mention lists**: reading posts nothing and answers nothing, so any room the server lets the bot see is readable — the server's visibility rules are the only gate.
 
 
 ### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?) — read recent context
 ### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?) — read recent context
 
 
-Reads the newest messages of a room (`client.get_room_events`) or of one thread (`client.get_thread_events`), returned oldest-first (sorted by the timestamps each message carries, not the server's ordering). `limit` 1-100 (default 20), `has_older` in the result says whether more history exists. Author logins come from the timeline page's own `users_by_id` directory first; actors missing there are hydrated via `batch_get_room_members`. Same room resolution and the same thread-root rule as `chatto_send_room` (a reply inside a thread is refused as a root). Not gated on the mention lists either — the server's own visibility rules decide what the bot may read.
+Reads the newest messages of a room (`client.get_room_events`) or of one thread (`client.get_thread_events`), returned oldest-first (`_oldest_first`, the same ordering the thread seeding uses — the server's own ordering is not a contract). `limit` 1-100 (default 20), `has_older` in the result says whether more history exists. Author logins come from the timeline page's own `users_by_id` directory first; actors missing there are hydrated via `batch_get_room_members`. Same room resolution and the same thread-root rule as `chatto_send_room` — both go through `_resolve_thread_root`, so the two tools cannot disagree about what a root is; a thread page that omits its root gets it prepended (the root is already in hand from that check). Not gated on the mention lists either — the server's own visibility rules decide what the bot may read.
 
 
 ### chatto_send_dm(user_login|user_id, message) — DM a user
 ### chatto_send_dm(user_login|user_id, message) — DM a user
 
 
@@ -63,6 +63,7 @@ Looks up the user by login or ID and posts into the DM room.
 - **Async handlers need `is_async=True`** — the default is sync; an `async def` handler registered without it returns an unawaited coroutine and the dispatcher fails with `tool_result_contract: unsupported result type: coroutine` (`tools/registry.py: dispatch()`).
 - **Async handlers need `is_async=True`** — the default is sync; an `async def` handler registered without it returns an unawaited coroutine and the dispatcher fails with `tool_result_contract: unsupported result type: coroutine` (`tools/registry.py: dispatch()`).
 - **Handlers must return JSON strings** (`json.dumps({...})`), never plain dicts — `_normalize_handler_result` accepts only str or the multimodal envelope. Annotate them `-> str` too: the annotation is what makes a regression to `dict` a type error instead of a runtime break.
 - **Handlers must return JSON strings** (`json.dumps({...})`), never plain dicts — `_normalize_handler_result` accepts only str or the multimodal envelope. Annotate them `-> str` too: the annotation is what makes a regression to `dict` a type error instead of a runtime break.
 - **Early exits raise `_ToolRefusal`, they do not return.** The `async with _tool_client(cfg)` context manager and the `_refuse(...)` helper keep one result shape per failure; the `_handle_*` wrapper turns a refusal back into its JSON and catches `ChattoError`/`ChattoAuthError` around it. Never `except Exception: pass` — the AGENTS.md rule about naming each recovery applies here too.
 - **Early exits raise `_ToolRefusal`, they do not return.** The `async with _tool_client(cfg)` context manager and the `_refuse(...)` helper keep one result shape per failure; the `_handle_*` wrapper turns a refusal back into its JSON and catches `ChattoError`/`ChattoAuthError` around it. Never `except Exception: pass` — the AGENTS.md rule about naming each recovery applies here too.
+- **Numeric arguments go through `_int_arg`.** Every tool argument comes from a model, so a numeric one arrives as `"20"`, `"twenty"`, `0` or `3.5`. `int()` raises on the word, and `0 or default` silently widens the answer past what was asked. `_int_arg(args, key, default, low=…, high=…)` refuses anything it cannot read instead.
 - **Config access: never read `os.environ` in handlers.** `.env` credentials like `CHATTO_TOKEN` are profile-scoped and NOT visible in cron fires' os.environ, and `hermes_validate_config` never runs in a fresh cron session. Resolve config through `_chatto_config_for_tools()` in adapter.py: (1) `_resolved_chatto_configs[_profile_scope_key()]` — the snapshot `hermes_validate_config` installed for *this* profile, only after it accepted the config; the map is keyed by `hermes_home_key()` because Hermes multiplexes profiles in one process, so a single module-level slot would hand the tools whichever profile validated last; (2) a fresh `load_gateway_config()` read for the `chatto-platform` platform entry, so `config.yaml`'s own `extra` block is honoured, re-filled from the installed secret scope (`agent.secret_scope.current_secret_scope()`) or, when none is installed, from `build_profile_secret_scope(get_hermes_home())`, which re-reads the profile's own `.env` and works in gateway, cron and CLI alike; (3) clean error.
 - **Config access: never read `os.environ` in handlers.** `.env` credentials like `CHATTO_TOKEN` are profile-scoped and NOT visible in cron fires' os.environ, and `hermes_validate_config` never runs in a fresh cron session. Resolve config through `_chatto_config_for_tools()` in adapter.py: (1) `_resolved_chatto_configs[_profile_scope_key()]` — the snapshot `hermes_validate_config` installed for *this* profile, only after it accepted the config; the map is keyed by `hermes_home_key()` because Hermes multiplexes profiles in one process, so a single module-level slot would hand the tools whichever profile validated last; (2) a fresh `load_gateway_config()` read for the `chatto-platform` platform entry, so `config.yaml`'s own `extra` block is honoured, re-filled from the installed secret scope (`agent.secret_scope.current_secret_scope()`) or, when none is installed, from `build_profile_secret_scope(get_hermes_home())`, which re-reads the profile's own `.env` and works in gateway, cron and CLI alike; (3) clean error.
 - **Never put a credential or a server URL in a tool schema.** The configured bot token must not be reachable from an argument: a `base_url` parameter would send it as `Authorization: Bearer …` to whatever host the caller names, and any message the bot reads is something the model could be talked into repeating. Take the client from `async with _tool_client(cfg) as client:` — it is the only sanctioned way to build one.
 - **Never put a credential or a server URL in a tool schema.** The configured bot token must not be reachable from an argument: a `base_url` parameter would send it as `Authorization: Bearer …` to whatever host the caller names, and any message the bot reads is something the model could be talked into repeating. Take the client from `async with _tool_client(cfg) as client:` — it is the only sanctioned way to build one.
 - **A half-delivered send says so.** The split, the thread routing and the stop-at-first-failure live in `_deliver_chunks`; `send()` turns its result into a `SendResult` and `_post_chunks` into a refusal carrying `partial: true` plus the `message_ids` that went out, so a retry repeats only what is missing. **Add a new send path by calling `_deliver_chunks`, not by writing a new loop** — two copies of the routing rules drift, and `send()` has to keep marking its own output seen (`on_posted`) so the realtime echo never comes back as input.
 - **A half-delivered send says so.** The split, the thread routing and the stop-at-first-failure live in `_deliver_chunks`; `send()` turns its result into a `SendResult` and `_post_chunks` into a refusal carrying `partial: true` plus the `message_ids` that went out, so a retry repeats only what is missing. **Add a new send path by calling `_deliver_chunks`, not by writing a new loop** — two copies of the routing rules drift, and `send()` has to keep marking its own output seen (`on_posted`) so the realtime echo never comes back as input.