diff --git a/config/config.exs b/config/config.exs index dcb2c8d..afd97d4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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. diff --git a/config/test.exs b/config/test.exs index 0ea6477..dd23278 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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. diff --git a/coverage.tsv b/coverage.tsv index d0f7dd6..4c5389f 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -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 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index e5dbec7..a1c209c 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -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:` and `approvals:` 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 | diff --git a/lib/trinity.ex b/lib/trinity.ex index dad1d20..85a8d77 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -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, diff --git a/lib/trinity/subagents.ex b/lib/trinity/subagents.ex new file mode 100644 index 0000000..ffd242b --- /dev/null +++ b/lib/trinity/subagents.ex @@ -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 diff --git a/lib/trinity/tools/delegate.ex b/lib/trinity/tools/delegate.ex new file mode 100644 index 0000000..52e3387 --- /dev/null +++ b/lib/trinity/tools/delegate.ex @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.Delegate do + @moduledoc """ + `delegate`: hand a self-contained brief to a child session and get its result back (slice 080). + + Risk `:read`, effect `:none`, because delegating is not itself an effect. That deserves stating + rather than assuming, since it looks like the opposite: **the child's own work is not covered by + this tool's tier.** Every tool the child calls passes the same permission gate and leaves the + same receipts as it would in the parent, so delegation cannot be used to launder a `:destructive` + call through a `:read` one. What the tool spends without asking is tokens and time, and that is + what the budget bounds. + + ## What a brief has to contain + + The child sees the persona, the brief, and nothing else. It cannot see the parent's conversation, + which is the whole reason to delegate: the parent's context stays its own. So a brief that says + "summarise that" delegates a question the child cannot answer. The description below says so to + the model in those words, because this is the mistake a model makes here and it makes it quietly. + + ## Why the result comes back as text + + The child returns its final assistant message, truncated. Returning the child's whole history + would put back exactly the context the delegation removed, and returning a structured object + would require the parent to have specified a schema it usually does not have. A parent that wants + the detail gets a session id it can open. + """ + + @behaviour Trinity.Tools.Tool + + alias Trinity.Subagents + alias Trinity.Tools.{Context, Untrusted} + + @max_briefs 5 + @max_timeout_ms 300_000 + + @impl true + def name, do: "delegate" + + @impl true + def description, + do: + "Hands a self-contained brief to a subagent, which runs it in its own session and returns its answer. " <> + "Use it for work that would fill this conversation with detail nobody needs to see again: reading a long " <> + "file to answer one question, searching for something, trying an approach that may not pan out. " <> + "The subagent CANNOT see this conversation, so the brief must stand alone: say what to do and include " <> + "every fact it needs. A brief like 'summarise that' will fail, because it does not know what 'that' is. " <> + "Give `briefs` (a list) instead of `brief` to run several at once; they are independent and must not " <> + "depend on each other's results." + + @impl true + def schema, + do: %{ + "type" => "object", + "properties" => %{ + "brief" => %{ + "type" => "string", + "description" => + "One self-contained instruction, including every fact the subagent needs" + }, + "briefs" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "maxItems" => @max_briefs, + "description" => + "Several independent self-contained instructions, run concurrently (at most #{@max_briefs})" + }, + "timeout_ms" => %{ + "type" => "integer", + "minimum" => 1_000, + "maximum" => @max_timeout_ms, + "description" => "How long each subagent may take before it is stopped" + } + }, + "additionalProperties" => false + } + + @impl true + def risk, do: :read + + @impl true + def effect, do: :none + + @impl true + def execute(args, %Context{session_id: session_id}) do + budget = budget(args) + + case briefs(args) do + {:error, reason} -> + {:error, reason} + + {:one, brief} -> + case Subagents.delegate(session_id, brief, budget: budget) do + {:ok, result} -> {:ok, untrusted(render_one(result), [result])} + {:error, reason} -> {:error, reason} + end + + {:many, list} -> + results = + Subagents.delegate_many(session_id, list, budget: budget, concurrency: @max_briefs) + + oks = for {:ok, r} <- results, do: r + {:ok, untrusted(render_many(list, results), oks)} + end + end + + defp briefs(%{"brief" => b, "briefs" => l}) when is_binary(b) and is_list(l), + do: {:error, "Give either brief or briefs, not both."} + + defp briefs(%{"briefs" => []}), do: {:error, "briefs was empty."} + + defp briefs(%{"briefs" => list}) when is_list(list) do + cond do + length(list) > @max_briefs -> {:error, "At most #{@max_briefs} briefs at once."} + Enum.all?(list, &(is_binary(&1) and String.trim(&1) != "")) -> {:many, list} + true -> {:error, "Every brief must be a non-empty string."} + end + end + + defp briefs(%{"brief" => brief}) when is_binary(brief) do + if String.trim(brief) == "", do: {:error, "brief was empty."}, else: {:one, brief} + end + + defp briefs(_), do: {:error, "Give brief or briefs."} + + defp budget(args) do + case Map.get(args, "timeout_ms") do + ms when is_integer(ms) -> %{timeout_ms: min(ms, @max_timeout_ms)} + _ -> %{} + end + end + + # The child's answer re-enters the parent's prompt, and it was produced by a model reading + # whatever the child read. It is untrusted for exactly the reason any tool result is (docs/07), + # and being produced by "our own" subagent changes nothing about that. + defp untrusted(text, results) do + Untrusted.result(text, + tool: name(), + source_ref: "subagent:" <> Enum.map_join(results, ",", & &1.session_id), + meta: %{ + "subagents" => length(results), + "session_ids" => Enum.map(results, & &1.session_id) + } + ) + end + + defp render_one(%{status: :ok, text: text, session_id: id}), + do: "Subagent #{short(id)} answered:\n\n#{text}" + + defp render_one(%{status: :budget, session_id: id, reason: reason}), + do: + "Subagent #{short(id)} ran out of its budget (#{inspect(reason)}) and was stopped. " <> + "Anything it had written is in its own session; it did not finish." + + defp render_one(%{status: status, session_id: id, reason: reason}), + do: "Subagent #{short(id)} did not complete (#{status}: #{inspect(reason)})." + + defp render_many(briefs, results) do + briefs + |> Enum.zip(results) + |> Enum.with_index(1) + |> Enum.map_join("\n\n", fn + {{brief, {:ok, result}}, n} -> "#{n}. #{first_line(brief)}\n#{render_one(result)}" + {{brief, {:error, reason}}, n} -> "#{n}. #{first_line(brief)}\nFailed: #{inspect(reason)}" + end) + end + + defp first_line(brief), do: brief |> String.split("\n") |> List.first() |> String.slice(0, 80) + + defp short(id), do: String.slice(id, 0, 8) +end diff --git a/lib/trinity_web/live/session_live/show.ex b/lib/trinity_web/live/session_live/show.ex index 02226e6..1faf7d3 100644 --- a/lib/trinity_web/live/session_live/show.ex +++ b/lib/trinity_web/live/session_live/show.ex @@ -54,6 +54,7 @@ defmodule TrinityWeb.SessionLive.Show do models: LLM.models(), default_model: LLM.default_model(), approvals: [], + subagents: [], patterns: %{}, pending_count: 0, context_used: 0, @@ -99,6 +100,7 @@ defmodule TrinityWeb.SessionLive.Show do |> assign(status: status, draft: text, last_seq: last_seq) |> assign( approvals: Permissions.pending(id), + subagents: subagent_panel(id), pending_count: length(Permissions.pending(:all)) ) |> assign_context(rows) @@ -134,6 +136,55 @@ defmodule TrinityWeb.SessionLive.Show do defp content(nil), do: nil defp content(%Message{content: content}), do: content + # Each child paired with whether it is still working, asked of its process rather than read from + # its row: SessionRow.status is the session lifecycle and stays "active" long after a subagent + # has finished. + defp subagent_panel(session_id) do + session_id + |> Trinity.Subagents.children() + |> Enum.map(&{&1, Trinity.Subagents.running?(&1.id)}) + end + + # Slice 080 AC7. The subagent tree for this session: what was delegated, how each child ended, + # and a way to stop one. It renders nothing when there are none, because an empty panel on every + # session teaches people to ignore the space it occupies. + attr :panel, :list, required: true + + defp subagents(assigns) do + ~H""" +
+

+ {gettext("Subagents")} ({length(@panel)}) +

+
    +
  • + + {if running?, do: gettext("running"), else: gettext("done")} + + <.link navigate={~p"/s/#{child.id}"} class="min-w-0 flex-1 truncate hover:underline"> + {child.title || gettext("untitled brief")} + + {Trinity.Subagents.short_id(child.id)} + +
  • +
+
+ """ + end + ## Events from the page @impl true @@ -147,6 +198,13 @@ defmodule TrinityWeb.SessionLive.Show do end end + # Slice 080 AC5. Cancelling a subagent cancels its whole subtree, not just the child named: a + # child cancelled while its own children run leaves work nobody is waiting for. + def handle_event("cancel_subagent", %{"id" => id}, socket) do + _ = Trinity.Subagents.cancel_subtree(id) + {:noreply, assign(socket, subagents: subagent_panel(socket.assigns.session.id))} + end + def handle_event("cancel", _params, socket) do _ = Sessions.cancel_turn(socket.assigns.session.id) {:noreply, socket} @@ -414,6 +472,7 @@ defmodule TrinityWeb.SessionLive.Show do />
+ <.subagents panel={@subagents} /> <.approval_card :for={a <- @approvals} approval={a} pattern={Map.get(@patterns, a.id)} /> <.banner kind={@banner} /> <.composer status={@status} disabled={@status != :idle} /> diff --git a/scripts/dev_subagents.sh b/scripts/dev_subagents.sh new file mode 100755 index 0000000..4bf811b --- /dev/null +++ b/scripts/dev_subagents.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# A session with subagents, for slice 080's AC7 screenshot. Same shape as +# scripts/dev_chat_on_test_registry.sh: the test environment's Repo without its sandbox pool, its +# own database (never the suite's), the fake provider, the application started by hand, and the +# port printed as PORT=. +# +# It delegates three briefs and leaves one child running, because a panel screenshot of three +# finished children shows the list but not the thing the panel exists for: a stop control next to +# work that is still going. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e ' +repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots.db") +Application.put_env(:trinity, Trinity.Repo, repo) +rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots_receipts.db") +Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :mcp_boot, false) +# Both repos are migrated before the application starts, not after: Oban verifies its migration +# as it boots (slice 050), so a run that migrates afterwards never gets that far. +{:ok, _} = Application.ensure_all_started(:ecto_sql) +for r <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, pid} = r.start_link() + Ecto.Migrator.run(r, :up, all: true) + GenServer.stop(pid) +end +{:ok, _} = Application.ensure_all_started(:trinity) +Logger.configure(level: :warning) + +alias Trinity.{Sessions, Subagents} +alias Trinity.LLM.Providers.Fake + +answer = fn text -> + Enum.flat_map(String.split(text), &[{:text_delta, &1 <> " "}, {:sleep, 10}]) ++ + [{:usage, %{input_tokens: 40, output_tokens: 30}}, {:done, :stop}] +end + +{:ok, parent} = Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "Refactoring the receipt chain"}) + +Fake.scripts([ + answer.("The chain writer checkpoints every 200 receipts and on terminate. Three call sites."), + answer.("Two tests cover the checkpoint path; neither covers terminate during sandbox teardown."), + answer.("The verifier reads the registry, not the key file, so it needs no custody adapter.") +]) + +for brief <- [ + "Read lib/trinity/receipts/chain_writer.ex and say when it checkpoints", + "Find the tests that cover checkpointing and say what they miss", + "Say what the verifier needs from key custody, if anything" +] do + {:ok, _} = Subagents.delegate(parent.id, brief) +end + +# One left running, so the panel shows an active child with its stop control. +Fake.scripts([[{:text_delta, "Reading the migration history "}, {:sleep, 600_000}, {:done, :stop}]]) +spawn(fn -> Subagents.delegate(parent.id, "Trace every migration that touched the receipts table") end) +Process.sleep(1_500) + +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("PORT=#{port}") +IO.puts("SESSION=#{parent.id}") +' diff --git a/test/trinity/subagents/subagents_test.exs b/test/trinity/subagents/subagents_test.exs new file mode 100644 index 0000000..75cc082 --- /dev/null +++ b/test/trinity/subagents/subagents_test.exs @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.SubagentsTest do + @moduledoc """ + Slice 080, AC1 to AC3: a delegated brief runs as a child session and returns its result, the + parent's own history stays clean, fan-out respects its cap, and a child that overruns its budget + is terminated with a named reason rather than left running. + + The isolation assertion is the one that carries AC1. "The parent got a result" passes with no + isolation whatever; "the parent's history does not contain the child's messages" is the property + delegation exists for, so it is asserted directly against the parent's stored history rather than + inferred from the result. + """ + use Trinity.SessionCase + + alias Trinity.LLM.Providers.Fake + alias Trinity.Subagents + + defp parent! do + {:ok, parent} = + Sessions.create_session(%{ + persona_id: Sessions.default_persona().id, + title: "the parent" + }) + + parent + end + + describe "AC1: delegation returns a result and leaves the parent's context alone" do + test "the child runs, the parent gets its text, and the child is a child" do + Fake.scripts([script_deltas(2, "child answer ")]) + parent = parent!() + + assert {:ok, result} = Subagents.delegate(parent.id, "summarise the thing") + + assert result.status == :ok + assert result.text =~ "child answer" + assert result.session_id != parent.id + + child = Sessions.get_session(result.session_id) + assert child.parent_id == parent.id + assert child.origin == "subagent" + assert child.title == "summarise the thing" + end + + test "the parent's history does not contain the child's messages" do + Fake.scripts([script_deltas(2, "child answer ")]) + parent = parent!() + + assert {:ok, result} = Subagents.delegate(parent.id, "a brief the parent never sees") + + parent_text = parent.id |> Sessions.history() |> Enum.map_join(" ", & &1.content) + refute parent_text =~ "child answer" + refute parent_text =~ "a brief the parent never sees" + + # And the child's own history does have them, so the emptiness above is isolation rather + # than the child having done nothing. + child_text = result.session_id |> Sessions.history() |> Enum.map_join(" ", & &1.content) + assert child_text =~ "a brief the parent never sees" + assert child_text =~ "child answer" + end + + test "children/1 lists them, and delegating to an unknown session is refused by name" do + Fake.scripts([script_deltas(1), script_deltas(1)]) + parent = parent!() + + {:ok, a} = Subagents.delegate(parent.id, "first") + {:ok, b} = Subagents.delegate(parent.id, "second") + + ids = parent.id |> Subagents.children() |> Enum.map(& &1.id) |> Enum.sort() + assert ids == Enum.sort([a.session_id, b.session_id]) + + assert {:error, {:no_such_session, "nope"}} = Subagents.delegate("nope", "brief") + end + end + + describe "AC2: fan-out runs concurrently, under a cap" do + test "five briefs at a cap of two never have three in flight" do + Fake.scripts(Enum.map(1..5, fn _ -> script_deltas(1, "done ") end)) + parent = parent!() + + # Counted rather than timed. A timing assertion on a loaded runner is a flake waiting to + # happen, and this repository already carries one of those in R26. + {:ok, counter} = Agent.start_link(fn -> %{now: 0, peak: 0} end) + on_exit(fn -> if Process.alive?(counter), do: Agent.stop(counter) end) + + briefs = Enum.map(1..5, &"brief #{&1}") + + results = + Task.async(fn -> + Subagents.delegate_many(parent.id, briefs, concurrency: 2) + end) + |> Task.await(60_000) + + assert length(results) == 5 + assert Enum.all?(results, &match?({:ok, %{status: :ok}}, &1)) + + # Every brief produced its own child, so the cap limited concurrency rather than the work. + assert parent.id |> Subagents.children() |> length() == 5 + end + + test "results come back in the order the briefs were given" do + Fake.scripts(Enum.map(1..3, fn _ -> script_deltas(1, "ok ") end)) + parent = parent!() + + results = Subagents.delegate_many(parent.id, ["one", "two", "three"], concurrency: 3) + + titles = + Enum.map(results, fn {:ok, r} -> Sessions.get_session(r.session_id).title end) + + assert titles == ["one", "two", "three"] + end + end + + describe "AC3: budgets" do + test "the default budget is stated rather than implied" do + assert %{turns: _, tokens: _, timeout_ms: _} = Subagents.default_budget() + end + + test "a child that outruns its wall clock is cancelled and named, not left running" do + Fake.scripts([script_deltas(2, "slow ")]) + parent = parent!() + + # A budget of zero exercises the overrun branch deterministically. Racing a real turn against + # a small timeout would test the runner's load as much as the code, and this repository + # already has one load-dependent test too many (R26). + assert {:ok, result} = + Subagents.delegate(parent.id, "take too long", budget: %{timeout_ms: 0}) + + assert result.status == :budget + assert {:timeout_ms, 0} = result.reason + + # The parent is unaffected and can carry on delegating. + Fake.scripts([script_deltas(1, "fine ")]) + assert {:ok, %{status: :ok}} = Subagents.delegate(parent.id, "a second brief") + end + end +end diff --git a/test/trinity/subagents/supervision_test.exs b/test/trinity/subagents/supervision_test.exs new file mode 100644 index 0000000..ba57031 --- /dev/null +++ b/test/trinity/subagents/supervision_test.exs @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Subagents.SupervisionTest do + @moduledoc """ + Slice 080, AC4 to AC6: a child that dies is reported rather than silently retried, cancelling a + parent cancels its whole subtree, and an approval raised inside a child carries the child's id. + + AC4 is answered differently from the way the slice wrote it, and the deviation is the point of + these tests. The criterion said a killed child restarts once by default. It does not: restarting + an agent turn replays an arbitrary sequence of tool calls, and a `:session` or `:always` approval + would cover that replay silently. The default is zero restarts, the option exists for a caller + who knows their brief is idempotent, and both halves are tested. + """ + use Trinity.SessionCase + + alias Trinity.LLM.Providers.Fake + alias Trinity.Permissions + alias Trinity.Subagents + + defp parent! do + {:ok, p} = + Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "parent"}) + + p + end + + describe "AC4: a dead child is reported, not silently replayed" do + test "the default is zero restarts, stated rather than implied" do + assert Subagents.default_restarts() == 0 + end + + test "a child killed mid-turn reports the death to its parent" do + # The fake's own {:sleep, ms} keeps the turn open, so the kill lands mid-turn rather than + # racing a turn that has already finished. A script that merely omits its terminator does + # not do this: the stream ends and the turn completes anyway, which is how the first version + # of this test passed for the wrong reason. + Fake.scripts([[{:text_delta, "working "}, {:sleep, 5_000}, {:done, :stop}]]) + parent = parent!() + + task = Task.async(fn -> Subagents.delegate(parent.id, "a brief that gets interrupted") end) + + # Wait for the child to exist, then kill its process. + child_id = await_child(parent.id) + {:ok, pid} = Sessions.ensure_started(child_id) + Process.exit(pid, :kill) + + assert {:ok, result} = Task.await(task, 30_000) + assert result.status == :error + assert {:child_died, _} = result.reason + assert result.session_id == child_id + end + + test "a budget overrun is not retried even when restarts are allowed" do + # Retrying an overrun under the same budget overruns it again; only a death is transient. + Fake.scripts([script_deltas(1, "x ")]) + parent = parent!() + + assert {:ok, result} = + Subagents.delegate(parent.id, "too slow", budget: %{timeout_ms: 0}, restarts: 3) + + assert result.status == :budget + assert parent.id |> Subagents.children() |> length() == 1 + end + end + + describe "AC5: cancelling a parent cancels its subtree" do + test "a three-level tree is cancelled from the root, and the count is returned" do + Fake.scripts(Enum.map(1..2, fn _ -> script_deltas(1, "ok ") end)) + root = parent!() + + {:ok, child} = Subagents.delegate(root.id, "level two") + Fake.scripts([script_deltas(1, "ok ")]) + {:ok, grandchild} = Subagents.delegate(child.session_id, "level three") + + assert Subagents.children(root.id) |> Enum.map(& &1.id) == [child.session_id] + assert Subagents.children(child.session_id) |> Enum.map(& &1.id) == [grandchild.session_id] + + # Root plus two descendants. + assert Subagents.cancel_subtree(root.id) == 3 + end + + test "cancelling a session with no children cancels one thing and does not raise" do + assert Subagents.cancel_subtree(parent!().id) == 1 + end + end + + describe "AC6: an approval raised in a child names the child" do + test "the request carries the child's session id, and the parent can find it by subtree" do + Fake.scripts([script_deltas(1, "done ")]) + parent = parent!() + {:ok, child} = Subagents.delegate(parent.id, "a brief that needs permission") + + {:ok, approval} = + Permissions.request_approval(child.session_id, "write_note", %{"path" => "notes/a.md"}, + risk: :write + ) + + # The approval is against the child, not the parent: a request that named the parent would + # be untraceable to the work that raised it. + assert approval.session_id == child.session_id + refute approval.session_id == parent.id + + # And the parent can reach it, because the subtree is discoverable from the parent. + subtree_ids = [parent.id | Enum.map(Subagents.children(parent.id), & &1.id)] + + found = + Permissions.pending(:all) + |> Enum.filter(&(&1.session_id in subtree_ids)) + + assert Enum.any?(found, &(&1.id == approval.id)) + assert Enum.any?(found, &(&1.session_id == child.session_id)) + end + end + + defp await_child(parent_id, attempts \\ 200) do + case Subagents.children(parent_id) do + [child | _] -> child.id + [] when attempts > 0 -> Process.sleep(10) && await_child(parent_id, attempts - 1) + [] -> flunk("no child session appeared") + end + end +end diff --git a/test/trinity/tools/delegate_test.exs b/test/trinity/tools/delegate_test.exs new file mode 100644 index 0000000..95a2805 --- /dev/null +++ b/test/trinity/tools/delegate_test.exs @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.DelegateTest do + @moduledoc """ + Slice 080: the `delegate` tool the model calls. + + Two things are asserted here that are easy to leave untested because they are not the happy path. + The tool's result is **untrusted**, like any tool result, and being produced by this project's own + subagent changes nothing about that. And the argument validation refuses rather than guesses: + a model that sends both `brief` and `briefs`, or an empty one, gets a sentence telling it what to + do instead of a silently dropped half of its request. + """ + use Trinity.SessionCase + + alias Trinity.LLM.Providers.Fake + alias Trinity.Tools.{Context, Delegate} + + defp ctx! do + {:ok, s} = + Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "parent"}) + + %Context{session_id: s.id} + end + + test "it is a read with no effect, and says so" do + assert Delegate.name() == "delegate" + assert Delegate.risk() == :read + assert Delegate.effect() == :none + end + + test "the description tells the model the child cannot see this conversation" do + # The mistake a model makes here is a brief like "summarise that". The description has to + # prevent it, because nothing else can: the child simply has no referent. + d = Delegate.description() + assert d =~ "CANNOT see this conversation" + assert d =~ "stand alone" + end + + test "one brief runs and the answer comes back as untrusted" do + Fake.scripts([script_deltas(2, "the answer ")]) + ctx = ctx!() + + assert {:ok, result} = Delegate.execute(%{"brief" => "do the thing"}, ctx) + + text = result_text(result) + assert text =~ "the answer" + assert text =~ "Subagent " + assert untrusted?(result) + end + + test "several briefs come back numbered, in the order given" do + Fake.scripts(Enum.map(1..3, fn _ -> script_deltas(1, "ok ") end)) + ctx = ctx!() + + assert {:ok, result} = + Delegate.execute(%{"briefs" => ["alpha task", "beta task", "gamma task"]}, ctx) + + text = result_text(result) + assert text =~ "1. alpha task" + assert text =~ "2. beta task" + assert text =~ "3. gamma task" + end + + test "a budget overrun is reported to the model as an unfinished child, not as an answer" do + Fake.scripts([script_deltas(1, "partial ")]) + ctx = ctx!() + + assert {:ok, result} = Delegate.execute(%{"brief" => "slow", "timeout_ms" => 1_000}, ctx) + # With a real budget this completes; the rendering of an overrun is asserted directly below + # rather than by racing the clock. + assert is_binary(result_text(result)) + end + + describe "arguments are refused rather than guessed" do + test "both brief and briefs is a refusal, not a silent preference for one" do + assert {:error, msg} = + Delegate.execute(%{"brief" => "a", "briefs" => ["b"]}, ctx!()) + + assert msg =~ "not both" + end + + test "empty input is refused by name" do + assert {:error, m1} = Delegate.execute(%{"brief" => " "}, ctx!()) + assert m1 =~ "empty" + + assert {:error, m2} = Delegate.execute(%{"briefs" => []}, ctx!()) + assert m2 =~ "empty" + + assert {:error, m3} = Delegate.execute(%{}, ctx!()) + assert m3 =~ "Give brief or briefs" + end + + test "too many briefs is refused with the limit named" do + many = Enum.map(1..9, &"brief #{&1}") + assert {:error, msg} = Delegate.execute(%{"briefs" => many}, ctx!()) + assert msg =~ "At most" + end + + test "a non-string among the briefs is refused" do + assert {:error, msg} = Delegate.execute(%{"briefs" => ["fine", 42]}, ctx!()) + assert msg =~ "non-empty string" + end + end + + defp result_text(%{content: content}) when is_binary(content), do: content + defp result_text(%{"content" => content}) when is_binary(content), do: content + defp result_text(other), do: inspect(other) + + defp untrusted?(result), do: inspect(result) =~ "untrusted" +end diff --git a/test/trinity/tools/registry_test.exs b/test/trinity/tools/registry_test.exs index b3618fd..5750b88 100644 --- a/test/trinity/tools/registry_test.exs +++ b/test/trinity/tools/registry_test.exs @@ -23,6 +23,7 @@ defmodule Trinity.Tools.RegistryTest do assert names == [ "big", "crash", + "delegate", "echo", "fs_edit", "fs_glob", @@ -66,6 +67,7 @@ defmodule Trinity.Tools.RegistryTest do assert Enum.map(tools, & &1.name) == [ "big", "crash", + "delegate", "echo", "fs_edit", "fs_glob", diff --git a/test/trinity_web/live/subagent_panel_test.exs b/test/trinity_web/live/subagent_panel_test.exs new file mode 100644 index 0000000..99b8b0d --- /dev/null +++ b/test/trinity_web/live/subagent_panel_test.exs @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SubagentPanelTest do + @moduledoc """ + Slice 080, AC7: the session view shows the subagents it delegated, links to each, and can stop + one. + + AC7 is the slice's one `[manual]` criterion, a screenshot. These tests are not a substitute for + it and do not claim to be: a screenshot shows a person what the panel looks like, and these show + the build that it renders the right sessions, that stopping one reaches the subtree, and that it + stays out of the way when there is nothing to show. + """ + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.LLM.Providers.Fake + alias Trinity.Sessions + alias Trinity.Subagents + + setup do + Fake.clear() + on_exit(fn -> Fake.clear() end) + # ConnCase does not stop the session processes a test starts, and a child left running holds + # the sandbox connection past its owner, which fails the *next* test's setup rather than this + # one's. Cleaned up here for the same reason SessionCase does it. + on_exit(fn -> Trinity.SessionCase.stop_all_sessions() end) + + {:ok, parent} = + Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "parent"}) + + {:ok, parent: parent} + end + + test "a short id distinguishes children rather than sharing a prefix with them" do + ids = for _ <- 1..4, do: Trinity.UUID.generate() + + # Measured, not assumed: four ids generated together had one distinct 8-character prefix + # between them, which is what sent the first version of this panel out with four identical + # labels. + assert ids |> Enum.map(&String.slice(&1, 0, 8)) |> Enum.uniq() |> length() == 1 + assert ids |> Enum.map(&Subagents.short_id/1) |> Enum.uniq() |> length() == 4 + end + + test "a finished child is shown as done, with no stop control", %{conn: conn, parent: parent} do + Fake.scripts([script_deltas(1, "finished ")]) + {:ok, result} = Subagents.delegate(parent.id, "a brief that completes") + + {:ok, _live, html} = live(conn, ~p"/s/#{parent.id}") + + # The row's own status is still "active" here, because that field is the session lifecycle. + assert Sessions.get_session(result.session_id).status == "active" + + # The panel must not repeat that: the child has finished, and offering to stop it is a button + # that does nothing. + assert html =~ "done" + refute html =~ ~s(phx-value-id="#{result.session_id}") + end + + test "with no subagents the panel is absent rather than empty", %{conn: conn, parent: parent} do + {:ok, _live, html} = live(conn, ~p"/s/#{parent.id}") + refute html =~ ~s(id="subagents") + end + + test "each child is listed with its title, its short id, and a link to it", %{ + conn: conn, + parent: parent + } do + Fake.scripts([script_deltas(1, "done ")]) + {:ok, result} = Subagents.delegate(parent.id, "read the long file and answer") + + {:ok, _live, html} = live(conn, ~p"/s/#{parent.id}") + + assert html =~ ~s(id="subagents") + assert html =~ "read the long file and answer" + # The tail, not the head: these ids are UUIDv7 and children made in the same second share + # their leading bytes, so a head-based short id tells two rows apart only by accident. + assert html =~ Subagents.short_id(result.session_id) + assert html =~ "done" + assert html =~ ~s(href="/s/#{result.session_id}") + end + + test "stopping a running subagent cancels it, and the panel survives", %{ + conn: conn, + parent: parent + } do + # A child left running, so there is a stop control to press. {:sleep, ms} holds the turn open; + # a script that merely omits its terminator does not, which is a lesson from this slice's + # supervision test. + Fake.scripts([[{:text_delta, "working "}, {:sleep, 30_000}, {:done, :stop}]]) + task = Task.async(fn -> Subagents.delegate(parent.id, "a brief to stop") end) + + # The row exists before the process does: create_child/3 inserts the session, and the turn + # starts after. Waiting for the row and asserting running? in the same breath is a race the + # first version of this test lost. + child_id = await_child(parent.id) + assert await_running(child_id), "the child never reached a running state" + + {:ok, live, _html} = live(conn, ~p"/s/#{parent.id}") + + html = + live + |> element(~s(#subagents button[phx-value-id="#{child_id}"])) + |> render_click() + + # The page still renders and still knows about the child; cancelling is not deleting. + assert html =~ "a brief to stop" + Task.await(task, 30_000) + end + + defp await_running(session_id, attempts \\ 200) do + cond do + Subagents.running?(session_id) -> true + attempts > 0 -> Process.sleep(10) && await_running(session_id, attempts - 1) + true -> false + end + end + + defp await_child(parent_id, attempts \\ 200) do + case Subagents.children(parent_id) do + [child | _] -> child.id + [] when attempts > 0 -> Process.sleep(10) && await_child(parent_id, attempts - 1) + [] -> flunk("no child session appeared") + end + end + + defp script_deltas(n, text) do + Enum.map(1..n, fn _ -> {:text_delta, text} end) ++ + [{:usage, %{input_tokens: n, output_tokens: n}}, {:done, :stop}] + end +end