Skip to content

Commit da9eebf

Browse files
authored
Merge slice/020-tool-protocol-registry
Slice 020: tool protocol and registry. PROOF.md at slices/020-tool-protocol-registry/PROOF.md; gate and postgres jobs green on the closing tree. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 4f7e73e + b01d3ec commit da9eebf

42 files changed

Lines changed: 1817 additions & 29 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ standards register names the rows that ask for them.
3737
| 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved |
3838
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved |
3939
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | approved |
40-
| 020 | Tool protocol + registry | 2 Tools | M | 012 | planned |
40+
| 020 | Tool protocol + registry | 2 Tools | M | 012 | done |
4141
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned |
4242
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned |
4343
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | planned |

‎VERSIONS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ never pin a version hex marks as retired or vulnerable.
135135
| `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. |
136136
| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. |
137137
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. |
138+
| `jsv` | ~> 0.23 | ✅ in `mix.lock` | JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas). |
138139
| `rustler` | ~> 0.38 | ✅ in `mix.lock` | Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it. |
139140

140141
### Dev and quality

‎config/test.exs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import Config
77
# The MIX_TEST_PARTITION environment variable can be used
88
# to provide built-in test partitioning in CI environment.
99
# Run `mix help test` for more information.
10+
# Slice 020: the test tools, and the toolset the core ones belong to. AC1's one config line
11+
# is the `modules:` entry; a tool module in test/support plus this line is the whole change.
12+
config :trinity, :tools,
13+
modules: [
14+
Trinity.TestTools.Echo,
15+
Trinity.TestTools.Sleep,
16+
Trinity.TestTools.Crash,
17+
Trinity.TestTools.Big
18+
],
19+
toolsets: %{core: ["echo", "sleep", "crash", "big"]},
20+
timeout_ms: 2_000
21+
1022
# Slice 012: sessions hibernate and stop quickly in tests so AC8 is observable in seconds.
1123
config :trinity, :sessions, idle_hibernate_ms: 200, idle_stop_ms: 60_000
1224

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ slice_id percent sha date
55
011 51.57 ec5334a 2026-09-20
66
012 60.82 019d9cf 2026-09-20
77
013 64.41 080c543 2026-09-20
8+
020 67.18 8a5b7ae 2026-09-20

‎docs/01-architecture.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ Trinity.Application
2323
├── Trinity.Sessions.Supervisor (DynamicSupervisor) # one Trinity.Sessions.Session per conversation. Slice 012, as built
2424
│ └── Trinity.Sessions.Session (gen_statem) # states: idle → thinking → tool_wait → approval_wait → compacting → error
2525
│ └── Task.Supervisor (started by the Session, linked, unnamed) # the model call and the tool calls of one turn
26-
├── Trinity.Tools.Supervisor # tool runtime (ports, browsers). Slice 020/022
26+
├── Trinity.Tools.Supervisor # Slice 020, as built: Trinity.Tools.TaskSupervisor (every tool
27+
│ │ # call of a turn runs under it) and Trinity.Tools.Registry
28+
│ │ # (GenServer over ETS). 022 adds the stateful runtimes beside them
2729
├── Trinity.Permissions.Gate # approval requests + allowlist cache. Slice 021
2830
├── Trinity.Receipts.Supervisor # Slice 024
2931
│ └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry; ADR-0013)
@@ -113,6 +115,17 @@ UI/Gateway ──user_message──▶ Session(gen_statem)
113115
Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe
114116
(rehydrate re-broadcasts the last state).
115117

118+
**Tool calls (Slice 020):** at `{:done, :tool_calls}` the Session hands the turn's calls to
119+
`Trinity.Sessions.ToolRunner.run_all/2`, the seam whose implementation in force is `Trinity.Tools.Runner`
120+
(config, so a test can put the stub back). The runner runs every call at once under
121+
`Trinity.Tools.TaskSupervisor`, each with its tool's timeout: lookup, `jsv` validation of the arguments
122+
(refused, never repaired), `Trinity.Permissions.decide/3` once, `execute/2`, the result cap. A crash, a timeout
123+
and an unknown name are error results the model reads; the Session writes one `tool` row per answer with the
124+
tool's definition digest. Each turn's request carries the declared surface (`Trinity.Tools.to_llm_tools/0`) and
125+
the assistant row records it (`provider_meta.tool_surface`); `Trinity.Tools.surface_diff/1` over a history names
126+
the calls a turn made outside it. Sessions depends on Tools; Tools depends on Permissions and never on Sessions
127+
(the runner implements the seam's functions without naming the behaviour, which would close a cycle).
128+
116129
**Effect path (Slice 024):** `Session → Permissions.decide → Effects.execute → Authority → tool.execute/2 (local) or a proposal (external adapter) → Receipts.append`. `Effects` is the only caller of `execute/2` for effectful tools; a census test enforces it. Reads emit query receipts.
117130

118131
**The page (Slice 013):** `TrinityWeb.SessionLive.Show` subscribes to `session:<id>` on mount, calls

‎docs/03-conventions.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@
2727
previous slice fails until a NOTES.md justification names the reason. The rule as originally written stored no
2828
baseline, so nothing could check it, which is the pattern CLAUDE.md §8 forbids.
2929

30+
## Tools (slice 020)
31+
32+
- A tool is a module implementing `Trinity.Tools.Tool` (`name/0`, `description/0`, `schema/0` as a JSON Schema
33+
map with string keys, `risk/0`, `effect/0`, `execute/2`, optional `timeout/0` and `format_result/1`) plus one
34+
line in `config :trinity, :tools` (`modules:`; `toolsets:` groups names). No core module changes; the
35+
registry test asserts `git grep TestTools lib/` finds nothing.
36+
- Tool modules are stateless. A runtime with state (a shell, a browser) is a child of `Trinity.Tools.Supervisor`
37+
the tool looks up.
38+
- `effect/0` is `:none` (a read), `:artifact` (a local write) or `:catalog` (an external effect). A `:catalog`
39+
tool is listed in `Trinity.Effects.Catalog`'s module attribute or it does not start; nothing registered at
40+
runtime may claim it. A runtime tool's name is namespaced (`mcp:<server>:<tool>`, `skill:<name>`); core
41+
names are reserved; the tier map in `Trinity.Permissions` is code and lists core names only.
42+
- A tool returns `{:ok, %Trinity.Tools.Result{}}` or `{:error, reason}`; the runner caps the content at
43+
`result_cap_bytes` (64 KB) with a marker. Arguments arrive validated; a tool never repairs them either.
44+
3045
## UI (decided at slice 013)
3146

3247
- Tokens live in `assets/css/app.css` and nothing else names a colour, a radius or a font: two daisyUI themes

‎docs/05-data-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
3636
| role | string | "system" \| "user" \| "assistant" \| "tool" |
3737
| content | text | plain text or JSON for tool payloads |
3838
| parts | map | structured content parts (text, tool_call, tool_result, image ref). Every part carries `origin`, `source_ref`, `digest` and `taint ∈ {trusted, untrusted, blocked}`; summaries inherit the maximum taint of their inputs (Slice 022) |
39-
| tool_call_id | string, nullable | |
39+
| tool_call_id | string, nullable | the assistant row's call id a `tool` row answers (Slice 012); its `parts` carry `tool`, `ok`, `tool_result` (`content`, `truncated`, `meta`, `artifacts`, or `error`) and `tool_definition_digest`, and the assistant row's `provider_meta.tool_surface` maps every declared tool name to its digest (Slice 020) |
4040
| usage | map, nullable | prompt/completion tokens, cost |
4141
| provider_meta | map | model, finish reason, latency |
4242
Append-only. Editing is a new message with `parts.supersedes`. One edit is allowed and named (slice 012): an

‎lib/trinity.ex‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,25 @@ defmodule Trinity do
77
# application locks the data directory before the Repo starts), Repo and UUID (the contexts
88
# use them), and the Sessions sub-boundary: a context TrinityWeb may call (docs/01). Slice
99
# 013 exports the schemas the chat renders, `Sessions.Message` and `Sessions.SessionRow`,
10-
# which the Sessions boundary exports itself; Store stays inside.
10+
# which the Sessions boundary exports itself; Store stays inside. Slice 020 exports the
11+
# Tools and Permissions sub-boundaries and `Effects.Catalog`, a plain module of this
12+
# boundary the tool registry reads (Effects becomes its own boundary at 024).
1113
use Boundary,
1214
deps: [],
1315
exports:
14-
[Paths, Repo, UUID, Config, Sessions, Sessions.Message, Sessions.SessionRow, LLM] ++
16+
[
17+
Paths,
18+
Repo,
19+
UUID,
20+
Config,
21+
Sessions,
22+
Sessions.Message,
23+
Sessions.SessionRow,
24+
LLM,
25+
Tools,
26+
Permissions,
27+
Effects.Catalog
28+
] ++
1529
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])
1630

1731
@moduledoc """

‎lib/trinity/application.ex‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ defmodule Trinity.Application do
3737
{Task.Supervisor, name: Trinity.LLM.TaskSupervisor},
3838
# Slice 012: one session process per conversation, found by id.
3939
{Registry, keys: :unique, name: Trinity.Registry},
40+
# Slice 020: the tool registry and the task supervisor tool calls run under, before
41+
# the sessions that call them.
42+
Trinity.Tools.Supervisor,
4043
Trinity.Sessions.Supervisor,
4144
# Start to serve requests, typically the last entry
4245
TrinityWeb.Endpoint

‎lib/trinity/effects/catalog.ex‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Effects.Catalog do
4+
@moduledoc """
5+
The effect catalog, resolved at compile time (docs/07, M4). Slice 020 opens it empty.
6+
7+
Every tool whose `effect/0` is `:catalog` (an external effect: send, spend, a provider
8+
mutation) is listed here by name with its risk tier, in a module attribute and nowhere
9+
else. `Trinity.Tools.Registry` admits a core `:catalog` tool only if its name is in this
10+
list and refuses a runtime registration claiming `:catalog` outright; the census test
11+
(slice 020 AC8) walks the tree and asserts no other path admits one. Slice 024 makes the
12+
membrane read it.
13+
"""
14+
15+
@catalog []
16+
17+
@doc "Every catalog tool as `{name, tier}`."
18+
@spec all() :: [{String.t(), atom()}]
19+
def all, do: @catalog
20+
21+
@doc "The names alone."
22+
@spec names() :: [String.t()]
23+
def names, do: Enum.map(@catalog, &elem(&1, 0))
24+
end

0 commit comments

Comments
 (0)