Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,17 @@ config :trinity, :tools,
Trinity.Skills.Tools.Learn,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
Trinity.Tools.Shell.Run,
# Slice 080: delegation to a child session.
Trinity.Tools.Delegate
],
toolsets: %{
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 080: delegation is its own toolset, so a persona can be given subagents without
# being given a shell, and vice versa.
subagents: ["delegate"],
# Slice 031: search over past conversations.
memory: ["session_search", "recall", "memory"],
# Slice 040: the skill tools.
Expand Down
7 changes: 6 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ config :trinity, :tools,
Trinity.Skills.Tools.Learn,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
Trinity.Tools.Shell.Run,
# Slice 080: delegation to a child session.
Trinity.Tools.Delegate
],
toolsets: %{
core: ["echo", "sleep", "crash", "big", "write_note"],
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 080: delegation is its own toolset, so a persona can be given subagents without
# being given a shell, and vice versa.
subagents: ["delegate"],
# Slice 031: search over past conversations.
memory: ["session_search", "recall", "memory"],
# Slice 040: the skill tools.
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ slice_id percent sha date
050 80.31 c01466d 2026-09-22
002 80.31 d65c48c 2026-09-23
025 80.31 2c4fe1f 2026-09-23
080 80.31 a7630ba 2026-09-24
2 changes: 1 addition & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ without anything failing.
| `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) |
| `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) |
| `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 |
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
| `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 |
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
| `Trinity.Desktop` | ex_tauri bridge | PubSub |
| `Trinity.Telemetry` | events, cost ledger, metrics | Repo |
Expand Down
2 changes: 2 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ defmodule Trinity do
Paths,
# Slice 025: the skills staging and the archive export seal through it.
Vault,
# Slice 080: the delegate tool and the session view's subagent panel call it.
Subagents,
Repo,
Repo.Receipts,
UUID,
Expand Down
306 changes: 306 additions & 0 deletions lib/trinity/subagents.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Subagents do
@moduledoc """
Delegation to child sessions (slice 080).

A subagent is an ordinary session with `origin: "subagent"` and its `parent_id` set. It runs one
turn against a brief and returns a result; its messages stay in its own history and never enter
the parent's.

**Why that isolation is the point rather than a detail.** The reason to delegate at all is that
the parent's context is finite and expensive. A delegation that copied the child's working back
into the parent would cost more than doing the work inline, and the parent would pay it twice:
once in tokens and once in a context window full of somebody else's reasoning. So the parent
receives the child's *result* and a session id it can open, and nothing else.

**What a subagent is not.** It is not a sandbox. A child inherits the parent's permission scope
and every effect it causes passes the same gate and leaves the same receipts as the parent's own
(docs/07). Delegation buys context isolation and concurrency; it buys no authority separation,
and a caller who wants less authority in a child has to say so through the toolset, not by
assuming that "subagent" means "contained".

## Why a subagent is not restarted

OTP's instinct is to restart a crashed child, and for a stateless worker that is right. It is
wrong here, and the reason is the one durable-execution systems state plainly: **match the
retry semantics to the side effect.** At-least-once is for idempotent work; at-most-once is for
anything that charges a card, sends a message or writes to the world. An agent turn is an
arbitrary sequence of tool calls, so it is the second kind by default.

Replaying a turn in this tree is not merely wasteful, it is unsound in a specific way. An
approval granted `:once` covers one execution and would correctly refuse the replay; but a
`:session` or `:always` grant would cover it silently, so a restart could re-run an effect the
person approved once and never expected twice. Nothing in the effects layer carries an
idempotency key today.

So `restarts:` defaults to **0**: a child that dies reports the death to its parent, which can
decide. A caller who knows a brief is idempotent (a search, a summary, a read) can raise it, and
the option is named `restarts` rather than `retries` so that the thing being repeated is visibly
a whole child rather than one call.

**This deviates from the slice's written acceptance criterion**, which said restart once by
default. The deviation and its reasoning are recorded in the slice's NOTES for the owner to
accept or veto.

## Budgets

Every delegation carries a budget and none is optional: turns, tokens and wall clock. A child
that exceeds one is terminated and the parent gets a named error rather than a crash, because a
parent that cannot tell "the child failed" from "the child is still thinking" cannot make a
decision either way.
"""

alias Trinity.Sessions

@default_budget %{turns: 1, tokens: 100_000, timeout_ms: 120_000}
# At-most-once by default. See the moduledoc: an agent turn is an arbitrary sequence of tool
# calls, and a `:session` or `:always` approval would cover a replay silently.
@default_restarts 0
@default_concurrency 3
@summary_bytes 4_000

@type brief :: String.t()
@type result :: %{
session_id: String.t(),
status: :ok | :budget | :error | :cancelled,
text: String.t(),
reason: term() | nil
}

@doc "The budget applied when a caller names none. Exposed so a test asserts it rather than restating it."
@spec default_budget() :: map()
def default_budget, do: @default_budget

@doc "How many times a dead child is retried when the caller names no number. Zero, deliberately."
@spec default_restarts() :: non_neg_integer()
def default_restarts, do: @default_restarts

@doc """
Runs one brief as a child of `parent_session_id` and returns its result.

Options: `:title`, `:persona_id`, `:budget` (a map merged over `default_budget/0`).
"""
@spec delegate(String.t(), brief(), keyword()) :: {:ok, result()} | {:error, term()}
def delegate(parent_session_id, brief, opts \\ []) when is_binary(brief) do
budget = Map.merge(@default_budget, Map.new(Keyword.get(opts, :budget, %{})))

restarts = Keyword.get(opts, :restarts, @default_restarts)

with {:ok, child} <- create_child(parent_session_id, brief, opts) do
{:ok, run(child, brief, budget, restarts)}
end
end

@doc """
Runs several briefs concurrently, at most `:concurrency` at a time, and returns their results in
the order the briefs were given.

The cap is not a performance knob. A parent that fans out without one turns a single delegation
into as many concurrent model calls as it has briefs, which is how a rate limit and a bill are
discovered at the same moment.
"""
@spec delegate_many(String.t(), [brief()], keyword()) :: [{:ok, result()} | {:error, term()}]
def delegate_many(parent_session_id, briefs, opts \\ []) when is_list(briefs) do
cap = Keyword.get(opts, :concurrency, @default_concurrency)

timeout =
Map.get(Map.new(Keyword.get(opts, :budget, %{})), :timeout_ms, @default_budget.timeout_ms)

Trinity.LLM.TaskSupervisor
|> Task.Supervisor.async_stream_nolink(
briefs,
fn brief -> delegate(parent_session_id, brief, opts) end,
max_concurrency: cap,
timeout: timeout + 5_000,
on_timeout: :kill_task,
ordered: true
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} -> {:error, {:subagent_exit, reason}}
end)
end

@doc """
Whether a child is still working.

Asked of the process, not of the row. `SessionRow.status` is the session's lifecycle - active,
archived, compacted - and a finished subagent is still `"active"` forever, so a panel that reads
it reports every child as running until somebody archives it. The screenshot for this slice's
AC7 is what caught that: four children, three of them long finished, all labelled active with a
stop button each.
"""
@spec running?(String.t()) :: boolean()
def running?(session_id) do
# `Sessions.state/1` answers with a view map, not the bare state atom. Comparing the map to
# `:idle` is never equal, so the first version of this reported every child as running and the
# panel's labels were wrong in the opposite direction from the bug it was written to fix.
case Sessions.state(session_id) do
%{state: state} -> state != :idle
_ -> false
end
rescue
_ -> false
catch
:exit, _ -> false
end

@doc """
A short, distinguishing form of a session id.

The **tail**, not the head. These ids are UUIDv7, which is time-ordered, so children created in
the same second share their leading bytes: measured, four ids generated together had one
distinct 8-character prefix between them. A short id whose whole job is to tell two rows apart
has to come from the random end.
"""
@spec short_id(String.t()) :: String.t()
def short_id(id) when is_binary(id), do: String.slice(id, -8, 8)

@doc "The children of a session, newest first."
@spec children(String.t()) :: [Sessions.SessionRow.t()]
def children(parent_session_id) do
Sessions.list_sessions(limit: 200)
|> Enum.filter(&(&1.parent_id == parent_session_id))
end

@doc """
Cancels a session's whole subtree, depth first, and returns how many were cancelled.

Depth first because cancelling a parent before its children leaves the children running with
nobody waiting for their results, which is the shape that produces work nobody asked for and
nobody reads.
"""
@spec cancel_subtree(String.t()) :: non_neg_integer()
def cancel_subtree(session_id) do
descendants =
session_id
|> children()
|> Enum.flat_map(fn child -> [child.id | Enum.map(children(child.id), & &1.id)] end)

Enum.each(descendants, &cancel_one/1)
cancel_one(session_id)
length(descendants) + 1
end

defp cancel_one(session_id) do
_ = Sessions.cancel_turn(session_id)
:ok
rescue
_ -> :ok
catch
:exit, _ -> :ok
end

# `get_session/1` casts its argument, so a value that is not a UUID reaches the repository as a
# malformed one and raises rather than returning nil. Slice 070 hit exactly this from a chat
# message and it crashed the router; a delegation brief is no more trustworthy than a chat
# message, so the same guard applies here rather than being learned twice.
defp create_child(parent_session_id, brief, opts) do
parent = if uuid?(parent_session_id), do: Sessions.get_session(parent_session_id)

if parent do
Sessions.create_session(%{
persona_id: Keyword.get(opts, :persona_id) || parent.persona_id,
origin: "subagent",
parent_id: parent_session_id,
title: Keyword.get(opts, :title) || title_from(brief),
origin_ref: %{"parent_session_id" => parent_session_id}
})
else
{:error, {:no_such_session, parent_session_id}}
end
end

defp uuid?(value) when is_binary(value),
do:
Regex.match?(
~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
value
)

defp uuid?(_), do: false

defp title_from(brief) do
brief |> String.split("\n") |> List.first() |> String.slice(0, 60)
end

# The awaiting shape is Trinity.Scheduler.Workers.RunTask's (slice 050), including the drain: a
# session's process broadcasts :idle once when it starts, and a wait that does not drain that one
# returns before the turn has run. Reused rather than rewritten, because writing it again is
# writing that bug again.
defp run(child, brief, budget, restarts) do
with :ok <- Sessions.subscribe(child.id),
{:ok, pid} <- Sessions.ensure_started(child.id),
:ok <- drain_start(child.id),
{:ok, _message} <- Sessions.send_user_message(child.id, brief) do
# The session process is monitored as well as subscribed: a process that dies mid-turn
# broadcasts nothing, so without this the parent waits out the whole budget for an answer
# that is never coming.
ref = Process.monitor(pid)
result = await(child.id, budget, ref)
Process.demonitor(ref, [:flush])
maybe_restart(result, child, brief, budget, restarts)
else
{:error, reason} -> %{session_id: child.id, status: :error, text: "", reason: reason}
end
end

# Only a death is retried, and only when the caller asked for it. A budget overrun is not a
# transient failure: running the same brief again under the same budget overruns it again.
defp maybe_restart(%{status: :error, reason: {:child_died, _}}, child, brief, budget, n)
when n > 0 do
run(child, brief, budget, n - 1)
end

defp maybe_restart(result, _child, _brief, _budget, _n), do: result

defp drain_start(session_id) do
receive do
{:session, ^session_id, {:state, :idle}} -> :ok
after
2_000 -> :ok
end
end

defp await(session_id, budget, ref) do
receive do
{:session, ^session_id, {:state, :idle}} ->
%{session_id: session_id, status: :ok, text: summary(session_id), reason: nil}

{:session, ^session_id, {:state, :error}} ->
%{session_id: session_id, status: :error, text: "", reason: :session_error}

{:session, ^session_id, {:error, reason}} ->
%{session_id: session_id, status: :error, text: "", reason: {:turn, reason}}

{:DOWN, ^ref, :process, _pid, reason} ->
%{session_id: session_id, status: :error, text: "", reason: {:child_died, reason}}

{:session, ^session_id, _other} ->
await(session_id, budget, ref)
after
budget.timeout_ms ->
_ = Sessions.cancel_turn(session_id)

%{
session_id: session_id,
status: :budget,
text: summary(session_id),
reason: {:timeout_ms, budget.timeout_ms}
}
end
end

defp summary(session_id) do
session_id
|> Sessions.history()
|> Enum.filter(&(&1.role == "assistant"))
|> List.last()
|> case do
nil -> ""
%{content: c} when is_binary(c) -> binary_part(c, 0, min(byte_size(c), @summary_bytes))
_ -> ""
end
end
end
Loading
Loading