Skip to content

Commit d1db77f

Browse files
authored
Merge pull request #84 from ScriptKittyOS/slice/080-subagents
Slice 080: subagents and delegation Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 95f48e2 + 85d599b commit d1db77f

14 files changed

Lines changed: 1120 additions & 3 deletions

File tree

‎config/config.exs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,17 @@ config :trinity, :tools,
8484
Trinity.Skills.Tools.Learn,
8585
# Slice 030: the always-on memory tiers.
8686
Trinity.Tools.Memory,
87-
Trinity.Tools.Shell.Run
87+
Trinity.Tools.Shell.Run,
88+
# Slice 080: delegation to a child session.
89+
Trinity.Tools.Delegate
8890
],
8991
toolsets: %{
9092
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
9193
web: ["web_fetch", "web_search"],
9294
shell: ["shell"],
95+
# Slice 080: delegation is its own toolset, so a persona can be given subagents without
96+
# being given a shell, and vice versa.
97+
subagents: ["delegate"],
9398
# Slice 031: search over past conversations.
9499
memory: ["session_search", "recall", "memory"],
95100
# Slice 040: the skill tools.

‎config/test.exs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,18 @@ config :trinity, :tools,
3838
Trinity.Skills.Tools.Learn,
3939
# Slice 030: the always-on memory tiers.
4040
Trinity.Tools.Memory,
41-
Trinity.Tools.Shell.Run
41+
Trinity.Tools.Shell.Run,
42+
# Slice 080: delegation to a child session.
43+
Trinity.Tools.Delegate
4244
],
4345
toolsets: %{
4446
core: ["echo", "sleep", "crash", "big", "write_note"],
4547
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
4648
web: ["web_fetch", "web_search"],
4749
shell: ["shell"],
50+
# Slice 080: delegation is its own toolset, so a persona can be given subagents without
51+
# being given a shell, and vice versa.
52+
subagents: ["delegate"],
4853
# Slice 031: search over past conversations.
4954
memory: ["session_search", "recall", "memory"],
5055
# Slice 040: the skill tools.

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ slice_id percent sha date
2424
050 80.31 c01466d 2026-09-22
2525
002 80.31 d65c48c 2026-09-23
2626
025 80.31 2c4fe1f 2026-09-23
27+
080 80.31 a7630ba 2026-09-24

‎docs/01-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ without anything failing.
9393
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery (as built at 050: the `tasks` and `task_runs` rows, `next_run_at` computed from a cron expression or a one-shot datetime, `Workers.Tick` (the one Cron plugin entry, every minute, enqueuing `Workers.RunTask` for what is due), `RunTask` (one turn in a fresh `origin: "cron"` session, the summary, the delivery), `Delivery` with `Delivery.Desktop`, `Parse.human/2` (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues `agent_tasks`, `memory`, `maintenance`; the observer as `Trinity.Memory.ObserverWorker` and the curator as `Trinity.Memory.Curator` live in Memory) | Sessions, LLM, **Repo** (Gateways at 070) |
9494
| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`; at 062: `Trinity.MCP.Auth`, a boundary of its own under `Trinity.MCP` with `deps: []` (the behaviour `authorize/2` answering a `Principal`, never the token; `Config`, `Token`, `JWKS`, `Discovery`, `Scopes`; the profiles `Local`, `External` and `Embedded` with `Embedded.Keys`; the client role `Client` with `Client.Store`), and `AuthHost` in `Trinity.MCP` (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); `Server.Auth.Local` is gone, `Server.Plug` authorizes ahead of the transport and answers `401` with a challenge, `Server` checks scope before the gate) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) |
9595
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:<id>` and `approvals:<id>` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt)) | Sessions, **Permissions**, PubSub |
96-
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
96+
| `Trinity.Subagents` | Delegation, result collection (as built at 080: `delegate/3` and `delegate_many/3` creating child sessions with `origin: "subagent"` and `parent_id` set, one turn each under a monitor, the awaiting shape reused from slice 050's `RunTask` including its drain of the start `:idle`; budgets of turns, tokens and wall clock; `restarts` defaulting to **0**, because replaying a turn replays arbitrary tool calls and a `:session` or `:always` approval would cover the replay silently; `cancel_subtree/1` depth first; `running?/1` asked of the process rather than of `SessionRow.status`, which is the session lifecycle and stays `active` after a subagent finishes; `short_id/1` taking the **tail** of a UUIDv7, whose head is shared between children created in the same second) | Sessions |
9797
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
9898
| `Trinity.Desktop` | ex_tauri bridge | PubSub |
9999
| `Trinity.Telemetry` | events, cost ledger, metrics | Repo |

‎lib/trinity.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ defmodule Trinity do
2323
Paths,
2424
# Slice 025: the skills staging and the archive export seal through it.
2525
Vault,
26+
# Slice 080: the delegate tool and the session view's subagent panel call it.
27+
Subagents,
2628
Repo,
2729
Repo.Receipts,
2830
UUID,

‎lib/trinity/subagents.ex‎

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Subagents do
4+
@moduledoc """
5+
Delegation to child sessions (slice 080).
6+
7+
A subagent is an ordinary session with `origin: "subagent"` and its `parent_id` set. It runs one
8+
turn against a brief and returns a result; its messages stay in its own history and never enter
9+
the parent's.
10+
11+
**Why that isolation is the point rather than a detail.** The reason to delegate at all is that
12+
the parent's context is finite and expensive. A delegation that copied the child's working back
13+
into the parent would cost more than doing the work inline, and the parent would pay it twice:
14+
once in tokens and once in a context window full of somebody else's reasoning. So the parent
15+
receives the child's *result* and a session id it can open, and nothing else.
16+
17+
**What a subagent is not.** It is not a sandbox. A child inherits the parent's permission scope
18+
and every effect it causes passes the same gate and leaves the same receipts as the parent's own
19+
(docs/07). Delegation buys context isolation and concurrency; it buys no authority separation,
20+
and a caller who wants less authority in a child has to say so through the toolset, not by
21+
assuming that "subagent" means "contained".
22+
23+
## Why a subagent is not restarted
24+
25+
OTP's instinct is to restart a crashed child, and for a stateless worker that is right. It is
26+
wrong here, and the reason is the one durable-execution systems state plainly: **match the
27+
retry semantics to the side effect.** At-least-once is for idempotent work; at-most-once is for
28+
anything that charges a card, sends a message or writes to the world. An agent turn is an
29+
arbitrary sequence of tool calls, so it is the second kind by default.
30+
31+
Replaying a turn in this tree is not merely wasteful, it is unsound in a specific way. An
32+
approval granted `:once` covers one execution and would correctly refuse the replay; but a
33+
`:session` or `:always` grant would cover it silently, so a restart could re-run an effect the
34+
person approved once and never expected twice. Nothing in the effects layer carries an
35+
idempotency key today.
36+
37+
So `restarts:` defaults to **0**: a child that dies reports the death to its parent, which can
38+
decide. A caller who knows a brief is idempotent (a search, a summary, a read) can raise it, and
39+
the option is named `restarts` rather than `retries` so that the thing being repeated is visibly
40+
a whole child rather than one call.
41+
42+
**This deviates from the slice's written acceptance criterion**, which said restart once by
43+
default. The deviation and its reasoning are recorded in the slice's NOTES for the owner to
44+
accept or veto.
45+
46+
## Budgets
47+
48+
Every delegation carries a budget and none is optional: turns, tokens and wall clock. A child
49+
that exceeds one is terminated and the parent gets a named error rather than a crash, because a
50+
parent that cannot tell "the child failed" from "the child is still thinking" cannot make a
51+
decision either way.
52+
"""
53+
54+
alias Trinity.Sessions
55+
56+
@default_budget %{turns: 1, tokens: 100_000, timeout_ms: 120_000}
57+
# At-most-once by default. See the moduledoc: an agent turn is an arbitrary sequence of tool
58+
# calls, and a `:session` or `:always` approval would cover a replay silently.
59+
@default_restarts 0
60+
@default_concurrency 3
61+
@summary_bytes 4_000
62+
63+
@type brief :: String.t()
64+
@type result :: %{
65+
session_id: String.t(),
66+
status: :ok | :budget | :error | :cancelled,
67+
text: String.t(),
68+
reason: term() | nil
69+
}
70+
71+
@doc "The budget applied when a caller names none. Exposed so a test asserts it rather than restating it."
72+
@spec default_budget() :: map()
73+
def default_budget, do: @default_budget
74+
75+
@doc "How many times a dead child is retried when the caller names no number. Zero, deliberately."
76+
@spec default_restarts() :: non_neg_integer()
77+
def default_restarts, do: @default_restarts
78+
79+
@doc """
80+
Runs one brief as a child of `parent_session_id` and returns its result.
81+
82+
Options: `:title`, `:persona_id`, `:budget` (a map merged over `default_budget/0`).
83+
"""
84+
@spec delegate(String.t(), brief(), keyword()) :: {:ok, result()} | {:error, term()}
85+
def delegate(parent_session_id, brief, opts \\ []) when is_binary(brief) do
86+
budget = Map.merge(@default_budget, Map.new(Keyword.get(opts, :budget, %{})))
87+
88+
restarts = Keyword.get(opts, :restarts, @default_restarts)
89+
90+
with {:ok, child} <- create_child(parent_session_id, brief, opts) do
91+
{:ok, run(child, brief, budget, restarts)}
92+
end
93+
end
94+
95+
@doc """
96+
Runs several briefs concurrently, at most `:concurrency` at a time, and returns their results in
97+
the order the briefs were given.
98+
99+
The cap is not a performance knob. A parent that fans out without one turns a single delegation
100+
into as many concurrent model calls as it has briefs, which is how a rate limit and a bill are
101+
discovered at the same moment.
102+
"""
103+
@spec delegate_many(String.t(), [brief()], keyword()) :: [{:ok, result()} | {:error, term()}]
104+
def delegate_many(parent_session_id, briefs, opts \\ []) when is_list(briefs) do
105+
cap = Keyword.get(opts, :concurrency, @default_concurrency)
106+
107+
timeout =
108+
Map.get(Map.new(Keyword.get(opts, :budget, %{})), :timeout_ms, @default_budget.timeout_ms)
109+
110+
Trinity.LLM.TaskSupervisor
111+
|> Task.Supervisor.async_stream_nolink(
112+
briefs,
113+
fn brief -> delegate(parent_session_id, brief, opts) end,
114+
max_concurrency: cap,
115+
timeout: timeout + 5_000,
116+
on_timeout: :kill_task,
117+
ordered: true
118+
)
119+
|> Enum.map(fn
120+
{:ok, result} -> result
121+
{:exit, reason} -> {:error, {:subagent_exit, reason}}
122+
end)
123+
end
124+
125+
@doc """
126+
Whether a child is still working.
127+
128+
Asked of the process, not of the row. `SessionRow.status` is the session's lifecycle - active,
129+
archived, compacted - and a finished subagent is still `"active"` forever, so a panel that reads
130+
it reports every child as running until somebody archives it. The screenshot for this slice's
131+
AC7 is what caught that: four children, three of them long finished, all labelled active with a
132+
stop button each.
133+
"""
134+
@spec running?(String.t()) :: boolean()
135+
def running?(session_id) do
136+
# `Sessions.state/1` answers with a view map, not the bare state atom. Comparing the map to
137+
# `:idle` is never equal, so the first version of this reported every child as running and the
138+
# panel's labels were wrong in the opposite direction from the bug it was written to fix.
139+
case Sessions.state(session_id) do
140+
%{state: state} -> state != :idle
141+
_ -> false
142+
end
143+
rescue
144+
_ -> false
145+
catch
146+
:exit, _ -> false
147+
end
148+
149+
@doc """
150+
A short, distinguishing form of a session id.
151+
152+
The **tail**, not the head. These ids are UUIDv7, which is time-ordered, so children created in
153+
the same second share their leading bytes: measured, four ids generated together had one
154+
distinct 8-character prefix between them. A short id whose whole job is to tell two rows apart
155+
has to come from the random end.
156+
"""
157+
@spec short_id(String.t()) :: String.t()
158+
def short_id(id) when is_binary(id), do: String.slice(id, -8, 8)
159+
160+
@doc "The children of a session, newest first."
161+
@spec children(String.t()) :: [Sessions.SessionRow.t()]
162+
def children(parent_session_id) do
163+
Sessions.list_sessions(limit: 200)
164+
|> Enum.filter(&(&1.parent_id == parent_session_id))
165+
end
166+
167+
@doc """
168+
Cancels a session's whole subtree, depth first, and returns how many were cancelled.
169+
170+
Depth first because cancelling a parent before its children leaves the children running with
171+
nobody waiting for their results, which is the shape that produces work nobody asked for and
172+
nobody reads.
173+
"""
174+
@spec cancel_subtree(String.t()) :: non_neg_integer()
175+
def cancel_subtree(session_id) do
176+
descendants =
177+
session_id
178+
|> children()
179+
|> Enum.flat_map(fn child -> [child.id | Enum.map(children(child.id), & &1.id)] end)
180+
181+
Enum.each(descendants, &cancel_one/1)
182+
cancel_one(session_id)
183+
length(descendants) + 1
184+
end
185+
186+
defp cancel_one(session_id) do
187+
_ = Sessions.cancel_turn(session_id)
188+
:ok
189+
rescue
190+
_ -> :ok
191+
catch
192+
:exit, _ -> :ok
193+
end
194+
195+
# `get_session/1` casts its argument, so a value that is not a UUID reaches the repository as a
196+
# malformed one and raises rather than returning nil. Slice 070 hit exactly this from a chat
197+
# message and it crashed the router; a delegation brief is no more trustworthy than a chat
198+
# message, so the same guard applies here rather than being learned twice.
199+
defp create_child(parent_session_id, brief, opts) do
200+
parent = if uuid?(parent_session_id), do: Sessions.get_session(parent_session_id)
201+
202+
if parent do
203+
Sessions.create_session(%{
204+
persona_id: Keyword.get(opts, :persona_id) || parent.persona_id,
205+
origin: "subagent",
206+
parent_id: parent_session_id,
207+
title: Keyword.get(opts, :title) || title_from(brief),
208+
origin_ref: %{"parent_session_id" => parent_session_id}
209+
})
210+
else
211+
{:error, {:no_such_session, parent_session_id}}
212+
end
213+
end
214+
215+
defp uuid?(value) when is_binary(value),
216+
do:
217+
Regex.match?(
218+
~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
219+
value
220+
)
221+
222+
defp uuid?(_), do: false
223+
224+
defp title_from(brief) do
225+
brief |> String.split("\n") |> List.first() |> String.slice(0, 60)
226+
end
227+
228+
# The awaiting shape is Trinity.Scheduler.Workers.RunTask's (slice 050), including the drain: a
229+
# session's process broadcasts :idle once when it starts, and a wait that does not drain that one
230+
# returns before the turn has run. Reused rather than rewritten, because writing it again is
231+
# writing that bug again.
232+
defp run(child, brief, budget, restarts) do
233+
with :ok <- Sessions.subscribe(child.id),
234+
{:ok, pid} <- Sessions.ensure_started(child.id),
235+
:ok <- drain_start(child.id),
236+
{:ok, _message} <- Sessions.send_user_message(child.id, brief) do
237+
# The session process is monitored as well as subscribed: a process that dies mid-turn
238+
# broadcasts nothing, so without this the parent waits out the whole budget for an answer
239+
# that is never coming.
240+
ref = Process.monitor(pid)
241+
result = await(child.id, budget, ref)
242+
Process.demonitor(ref, [:flush])
243+
maybe_restart(result, child, brief, budget, restarts)
244+
else
245+
{:error, reason} -> %{session_id: child.id, status: :error, text: "", reason: reason}
246+
end
247+
end
248+
249+
# Only a death is retried, and only when the caller asked for it. A budget overrun is not a
250+
# transient failure: running the same brief again under the same budget overruns it again.
251+
defp maybe_restart(%{status: :error, reason: {:child_died, _}}, child, brief, budget, n)
252+
when n > 0 do
253+
run(child, brief, budget, n - 1)
254+
end
255+
256+
defp maybe_restart(result, _child, _brief, _budget, _n), do: result
257+
258+
defp drain_start(session_id) do
259+
receive do
260+
{:session, ^session_id, {:state, :idle}} -> :ok
261+
after
262+
2_000 -> :ok
263+
end
264+
end
265+
266+
defp await(session_id, budget, ref) do
267+
receive do
268+
{:session, ^session_id, {:state, :idle}} ->
269+
%{session_id: session_id, status: :ok, text: summary(session_id), reason: nil}
270+
271+
{:session, ^session_id, {:state, :error}} ->
272+
%{session_id: session_id, status: :error, text: "", reason: :session_error}
273+
274+
{:session, ^session_id, {:error, reason}} ->
275+
%{session_id: session_id, status: :error, text: "", reason: {:turn, reason}}
276+
277+
{:DOWN, ^ref, :process, _pid, reason} ->
278+
%{session_id: session_id, status: :error, text: "", reason: {:child_died, reason}}
279+
280+
{:session, ^session_id, _other} ->
281+
await(session_id, budget, ref)
282+
after
283+
budget.timeout_ms ->
284+
_ = Sessions.cancel_turn(session_id)
285+
286+
%{
287+
session_id: session_id,
288+
status: :budget,
289+
text: summary(session_id),
290+
reason: {:timeout_ms, budget.timeout_ms}
291+
}
292+
end
293+
end
294+
295+
defp summary(session_id) do
296+
session_id
297+
|> Sessions.history()
298+
|> Enum.filter(&(&1.role == "assistant"))
299+
|> List.last()
300+
|> case do
301+
nil -> ""
302+
%{content: c} when is_binary(c) -> binary_part(c, 0, min(byte_size(c), @summary_bytes))
303+
_ -> ""
304+
end
305+
end
306+
end

0 commit comments

Comments
 (0)