Author SHA1 Message Date
HomerandClaude Opus 5.5 fe0fa62a51 2.0.0-beta.6
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:29:56 +00:00
HomerandClaude Opus 5.5 f3db970c9d Team events: a change inside the pace window is said at its end, so the last state always goes out; the main agent waiting for a slot is the waiting_for_model phase
ci / check (push) Waiting to run
Found by the full suite: the link's tree could miss a subagent's last change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:38:12 +00:00
HomerandClaude Opus 5.5 ae1201540a CHANGELOG: Unreleased — harness spec 3.4.0 (progress phases, subagents v2)
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:32:34 +00:00
HomerandClaude Opus 5.5 b70cfe483b The link (spec 3.4.0): capabilities progress, tree and agents; the team event carries the tree; _lembas/team, /transcript, /findings, /stop; progress and tree in status; subagent on an approval; an agent chat's Min/Max and said read per turn
ci / check (push) Waiting to run
acp.md: a turn without agents keeps the device's own setting (an older server), as before 3.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:32:03 +00:00
HomerandClaude Opus 5.5 ecfd1b0b8a TUI (spec 3.4.0): the spinner line is the progress phase; /team shows the tree with each agent's phase, context and messages; /team open (a subagent's session, read-only), /team findings, /context <subagent>
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:28:28 +00:00
HomerandClaude Opus 5.5 8c5edc0f01 Subagents v2 in the CLI (harness spec 3.4.0): the main agent's tree, modes and requests, findings, finishing and running out of context; progress phases
ci / check (push) Waiting to run
team/team.ts: ids, positions, parents; only the main agent adds, assigns, replaces, stops and
decides; sub-managers; slots; modes inherited and never widened, request options and grants;
findings; team_finish; pause and out_of_context; ended subagents kept; the tree's data.
Seven new tools. A subagent's call is decided by team/delegation.ts (itself, the main agent
through a request, or the person). Its engine does not compact: at pause_at it is paused and
gets one final turn. Every engine says its progress as phase events (session/progress.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:21:29 +00:00
HomerandClaude Opus 5.5 b66891e186 Harness spec 3.4.0: an agent chat's subagents are the device's; the tree's cases
ci / check (push) Waiting to run
acp.md *Subagents in an agent chat*: capability agents carries the chat's {min, max} (and
subagents: false) down on every turn and _lembas/configure, said (the person's earlier words)
on session/new; the tree, progress, approvals, findings and transcripts come up; what the server
keeps. subagents.md §14. team.json: ten 3.4.0 cases (only the main agent, sub-managers and parent,
slots, modes and request options, whoami, finish, findings, running out of context and replacing,
requests and decisions, assign); a member's own task is refused only_main. subagents.md: the
subagents under a sub-manager that ended by itself move under its parent until it is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:19:17 +00:00
HomerandClaude Opus 5.5 6199028358 Progress phases and delegation rules (harness spec 3.4.0): the text and line of each phase, who decides a subagent's call
ci / check (push) Waiting to run
Conformance areas progress and delegation pass. delegation.json: own_reason marks the cases
whose deny reason is each project's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:12:29 +00:00
HomerandClaude Opus 5.5 4b8786b7f6 Harness spec 3.4.0: progress phases, and subagents v2 — the main agent and its tree
ci / check (push) Waiting to run
progress.md: thirteen fixed phases (queued … failed), each { phase, since, data },
their texts as shared i18n keys (loop.json progress), one line every client draws,
sent beside the old status text in /api/v2 and over the link (capability progress).

subagents.md §5–§13 (supersedes team.md where they differ): only the main agent
adds, assigns, replaces, stops and decides; sub-managers direct those placed under
them; ids, positions, parents; Min above 1 opens slots; permissions inherited and
never widened, request options decided by the main agent, what nobody in the tree
can allow goes to the person; team_finish, findings, running out of context;
transcripts read-only; the tree over the link (capability tree). New tools
team_whoami, team_finish, team_findings, team_request, team_decide, team_assign,
team_replace. Conformance: progress, delegation; team updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:10:36 +00:00
HomerandClaude Opus 5.5 6a0e0ed145 Subagents: Min and Max, one range with two handles, counting this agent (harness spec 3.3.0)
ci / check (push) Waiting to run
/effort's Subagents row and /team size are a two-handle range from 1 to the cap (left/right move
the handle in focus, tab the other, enter sets both); the dialog gains a range row. Every text says
Min and Max, never fewest/most, and that the count includes this agent: "Subagents: Min 1, Max 4 -
1 is this agent alone". /settings shows the same beside agents.min / agents.max; config schema
and the bounds refusal likewise. CHANGELOG in the 2.0.0-beta.5 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:09:31 +00:00
HomerandClaude Opus 5.5 3a206959b6 Harness spec 3.3.0: the Subagents setting is Min and Max, one range with two handles, counting the agent itself
ci / check (push) Waiting to run
loop.json subagents.setting: min/max are "Min" and "Max" (not fewest/most), with a hint and a short
form saying the numbers include the session's own agent - 1 is the agent alone, Max 4 the agent and
up to three subagents. team.md §2 and subagents.md §4 state that, and the UI rule: one range from 1
to the cap with two handles (a slider; two numbers on one line in a terminal). team_add says the
size includes the caller. Conformance team: max 2 is the lead and one task subagent.

Still 3.3.0, unreleased: edited in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:04:24 +00:00
HomerandClaude Opus 5.5 6993937522 2.0.0-beta.5
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:01:14 +00:00
HomerandClaude Opus 5.5 f88921667e CHANGELOG: the 2.0.0-beta.4 entry that was missed at release
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:09:36 +00:00
HomerandClaude Opus 5.5 0c3596d044 The model roster in the prompt: a subagent runs on another model only when the person names it (spec 3.3.0)
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:08:49 +00:00
HomerandClaude Opus 5.5 6e0582dc22 Subagents in the CLI (harness spec 3.3.0): the model, capacity, the offer, /context
ci / check (push) Waiting to run
- A task subagent or a team member runs on the session's model unless the
  person named another in this session's own messages (team/subagents.ts;
  engine.personSaid reads every prompt and mid-turn message, compacted ones
  included). Not named: refused; an agent file's own model is not used.
- Another model on the session's one-model server is refused; a one-request
  session model is not its own subagent, and nothing else is picked.
- The team is offered when a model could serve: the session's with two
  slots, or any usable model off its one-model server; the size note adds
  team_own when the session's model cannot serve its own.
- "Subagents" in /effort, /team, /settings and the config schema; the status
  bar reads "agents 3/4 · 2 subagents working".
- /context: used of the window, one bar by category, each category's share,
  what is free, and "N until auto-compact".
- Conformance runner for the subagents area (26 cases); helper -> task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:04:58 +00:00
HomerandClaude Opus 5.5 a26dd8a5e0 Harness spec 3.3.0: name its spec commit (02c7282) in the README and CHANGELOG
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:56:45 +00:00
HomerandClaude Opus 5.5 02c7282e74 Harness spec 3.3.0: subagents — the word, the model, capacity, the offer
ci / check (push) Waiting to run
- "Subagent" in every text a person or a model reads; "helper" retired
  (the team area's step helper_done is now task_done).
- A subagent runs on the session's model unless the person named another
  in this session's own messages; task's and team_add's `model` is refused
  otherwise. No setting chooses a subagent's model.
- Capacity: another model on the session's model's one-model server is
  refused; the session's model as its own subagent needs parallel >= 2 or
  no limit; no fallback.
- The agents {min, max} setting is shown as Subagents, offered when a model
  could serve; context.note.team_own when the session's model cannot.
- subagents.md, loop.json subagents, conformance/subagents.json (26 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:56:31 +00:00
76 changed files with 7233 additions and 352 deletions

No files matched your search

+87
View File
@@ -4,6 +4,93 @@ All notable changes to LLeMbas CLI. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/). [Semantic Versioning](https://semver.org/).
## [2.0.0-beta.6] — 2026-11-10
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
subagents organised as the main agent's tree.
### Added
- **Progress phases.** The line under the conversation says what the agent is doing in the words
every LLeMbas client uses — *Queued*, *Waiting for {model}*, *Reading the prompt: n / total
tokens (p%)*, *Thinking*, *Writing: n tok/s*, *Running {tool}: {what}*, *Waiting for you*,
*Waiting for subagents*, *Compacting the conversation*, *Retrying in n s*, *Stopped*, *Failed* —
with how long it has lasted, then the turn's time and tokens. The CLI keeps its own estimate of
how long the rest of a long prompt will take. Over the link each phase is a `phase` event and is
in the session's status (capability `progress`).
- **The main agent and its subagents.** Only the main agent adds, reassigns (`team_assign`),
replaces (`team_replace`), stops and decides; a subagent added with `lead: true` is a
sub-manager that directs the subagents placed under it (`parent`) and adds none. Each subagent
has an id (`s1`, `s2`, …), a name, a position, a parent, a task, a mode and a model, and can ask
`team_whoami`. With Min above 1 the main agent starts with Min − 1 open slots to fill.
- **Permissions only go down.** A subagent's mode is its parent's or stricter. A narrowed one may
be given request options (read, write, execute) and ask the main agent with `team_request`; the
main agent decides with `team_decide`, and never allows more than its own mode would. What
nobody in the tree could allow — a `bash` call in an all-Edit tree — is put to you, on an
approval card that names the subagent.
- **A subagent's end.** It reports and ends with `team_finish`. Its findings (`team_findings`) are
kept after it ends. When its context fills it does not compact: it is paused, gets one last
turn of at most 8 steps to save its findings and tell its parent, and ends; the main agent
replaces it or uses what it found.
- **Seeing the tree.** `/team` draws every agent with its position, state, model, mode, context
use, current phase and messages, the open slots and the subagents that ended; `/team open
<name>` shows a subagent's session read-only, live or after it ended; `/team findings <name>`;
`/context <name>` for a subagent's context.
- **An agent chat's subagents run on this device.** The chat's Min and Max come with every turn
(capability `agents`) and the tree, progress, findings and transcripts go back to the server
(capability `tree`: the `team` event with the tree, `_lembas/team`, `/transcript`, `/findings`,
`/stop`).
### Changed
- A subagent can no longer add subagents of its own or start a `task`; it asks the main agent.
`team_stop` is the main agent's alone.
## [2.0.0-beta.5] — 2026-11-09
Harness spec 3.3.0 (spec commit 02c7282): subagents run on your model unless you name another,
and `/context` shows where the window goes the way the side panel of the other apps does.
### Changed
- **Subagents run on the session's model**, unless you named another model in this session's own
messages — its id, the part after its provider, or its display name, as a whole word, in any
letter case. A `task` or `team_add` that asks for any other model is refused ("Use another
model for subagents only when the person names it."), and an agent file's own `model:` is no
longer used. Nothing in the settings chooses a subagent's model.
- **Only the logical capacity limits remain.** A named model on the same one-model server as the
session's model is refused ("That model is on the same one-model server as this chat's model;
name a model on another server."). On a model that serves one request at a time, a subagent on
that same model is refused ("This model serves one request at a time: name another model, on
another server, for subagents.") and nothing else is picked instead.
- **Subagents are offered** — the size setting, the team tools — when subagents are on and some
model could serve one: the session's, when it serves two requests at once, or any model of the
server that is not on its one-model server. When the session's model cannot serve its own
subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it
beside the setting.
- **"Subagents" is the word** for the setting, with **Min** and **Max**: the row beside the effort
in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status bar reads
`agents 3/4 · 2 subagents working` while there is a team.
- **The numbers count this agent**: `Subagents: Min 1, Max 4 — 1 is this agent alone`. Max 1 is
the agent alone, with no subagents; Max 4 is the agent and up to three subagents.
- **The setting is one range with two handles.** In `/effort` and `/team size` the Subagents row
shows `Min 1`, a track from 1 to the cap with a handle for each, and `Max 4`: ←→ move the handle in focus, tab switches to the other, enter
sets both for the session (Min never passes Max; Max goes up to `agents.cap`). `/team size` also
keeps the usual pairs and `<min> <max>` typed.
- **`/context`** shows the window as used tokens of the window and a percentage, one bar split by
category, every category with its tokens and its share of the window, what is free, and how far
it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now).
## [2.0.0-beta.4] — 2026-11-09
### Fixed
- **`lembas login` no longer loops on a sign-in the server stopped accepting.** With an earlier login
for the same server kept here (from an older install, or LLeMbas Desktop), login only refreshed it;
when the server refused the old token it said “run login again”, and running it again did the same.
Now a refused sign-in starts a new one, which replaces the old.
- The installer's last line says to run `lembas login`, not to edit `connections.yaml` (gone in 2.0).
## [2.0.0-beta.3] — 2026-11-09 ## [2.0.0-beta.3] — 2026-11-09
Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent
+70 -5
View File
@@ -15,17 +15,19 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|---|---| |---|---|
| `VERSION` | The spec's own version, apart from LLeMbas CLI's | | `VERSION` | The spec's own version, apart from LLeMbas CLI's |
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for | | `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent | | `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`, `team_finish`, `team_request`; `main`, spec 3.4.0: given to the main agent only, never to a subagent — `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer | | `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again | | `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
| `permission/arity.json` | How much of a command an "always allow" covers | | `permission/arity.json` | How much of a command an "always allow" covers |
| `permission/defaults.json` | The rules every session starts from | | `permission/defaults.json` | The rules every session starts from |
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class | | `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends | | `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `team` — a team's size, names, marks, refusals and texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes | | `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory | | `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt | | `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping | | `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
| `subagents.md` | Subagents: the word, which model one runs on (the session's, or one the person named), the capacity refusals, and when the Subagents setting is offered; from 3.4.0 (§5–§13) the main agent and its tree — only it adds, permissions inherited and never widened, request options, a subagent's life and its findings, what clients show |
| `progress.md` | Progress (3.4.0): the thirteen phases every client shows the same way, the data each carries, their texts, and where they are sent |
| `quirks.json` | What real servers do, and how both harnesses cope | | `quirks.json` | What real servers do, and how both harnesses cope |
| `conformance/<area>.json` | Cases with their expected results, run by both test suites | | `conformance/<area>.json` | Cases with their expected results, run by both test suites |
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability | | `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
@@ -33,7 +35,7 @@ page *Harness-parity* in LLeMbas CLI's wiki.
## Vocabulary ## Vocabulary
- **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one - **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one
tool** among the shared ones, so a read-only helper is given exactly the readers. tool** among the shared ones, so a read-only subagent is given exactly the readers.
- **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`). - **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`).
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same - **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
@@ -48,8 +50,9 @@ page *Harness-parity* in LLeMbas CLI's wiki.
page of the chat is open and attached (not a schedule, not a background job); `display`: the page of the chat is open and attached (not a schedule, not a background job); `display`: the
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal); session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in `forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
plan mode; `team` (3.2.0): the session's team setting allows more than one agent, on a model that plan mode; `team` (3.2.0): the session's team setting allows more than one agent, and a model could
serves two requests at once (`team.md` §2). A requirement that goes away between the offer and the call makes the call answer the serve as a subagent (3.3.0: `subagents.md` §4; before, the chat's model serving two requests at
once). A requirement that goes away between the offer and the call makes the call answer the
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
tool is offered from the start and answers `unavailable` while the requirement is not met. tool is offered from the start and answers `unavailable` while the requirement is not met.
@@ -68,6 +71,68 @@ page *Harness-parity* in LLeMbas CLI's wiki.
approval in plan mode and answers its `unavailable` text outside it. Their permission key is approval in plan mode and answers its `unavailable` text outside it. Their permission key is
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches. `plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
## 3.4.0
Written ahead of both implementations; the server vendors it as written, both carry it in the same
round (`progress.md`, `subagents.md` §5–§13).
- **Progress** (`progress.md`, `loop.json` `progress`): one fixed set of phases — `queued`,
`waiting_for_model`, `loading_model`, `reading_prompt`, `thinking`, `writing`, `running_tool`,
`waiting_for_person`, `waiting_for_subagents`, `compacting`, `retrying`, `stopped`, `failed` —
each a structured object `{ phase, since, …data }` with fixed fields, an English text that is
the shared i18n key, and one line every client draws: `text · elapsed`. Sent beside the old
`status` text in `/api/v2` and over the link (`acp.md` capability `progress`: the `phase` event,
`progress` in `status`); `status` stays. Area `progress`.
- **Subagents v2** (`subagents.md` §5–§13; supersedes `team.md` where they differ, which says so):
the **main agent** is the session's, permanent, and **alone adds, assigns, replaces, stops and
decides** (`offered: main`); a member with `lead: true` is a **sub-manager** that directs those
placed under it (`team_add`'s `parent`). Every subagent has an id (`s1`…), a name, a position, a
parent, a task, a mode and a model. Min above 1 opens `min − 1` **slots** from the start of the
reply. **Permissions are inherited and never widened**: a subagent's mode is its parent's or
stricter; a narrowed one may get **request options** (`read`, `write`, `execute`) and ask the
main agent (`team_request` → `team_decide`, a grant that never reaches past the main agent's own
mode); a call nobody in the tree can allow goes **to the person**. A subagent ends with
`team_finish`, is stopped, or **runs out of context**: paused, one final turn to save its
findings and tell its parent, then `out_of_context` and a report; findings and transcripts are
kept, readable by the parent, the main agent and the person. New tools `team_whoami`,
`team_finish`, `team_findings`, `team_request`, `team_decide`, `team_assign`, `team_replace`;
`team_add` gains `parent`, `mode`, `request`; `team_send` takes `parent`; `team_stop` is the main
agent's. `blocks/team_submanager.md`; `team_lead.md` and `team_member.md` reworded. Over the link,
capability `tree`: the `team` event, `_lembas/team`, `/transcript` (read-only), `/findings`,
`/stop`, `subagent` on an approval. **An agent chat's subagents are the device's** (`subagents.md`
§14, `acp.md` *Subagents in an agent chat*): capability `agents` carries the chat's `{ min, max }`
down on every turn (and `said`, the person's earlier words, on `session/new`); the tree, progress,
approvals, findings and transcripts come up. Areas `team` (cases changed where a member used to add or
stop, and new ones) and `delegation`.
## 3.3.0
Written ahead of both implementations as LLeMbas CLI 02c7282; both carry it in the same round (`subagents.md`).
- **Subagent** is the word everywhere a person or a model reads one — a `task` subagent and a
team's member alike; *helper* is retired. Tool names and aliases are unchanged. The `team`
conformance area's step `helper_done` is now **`task_done`**.
- **The model**: a subagent runs on the session's model, unless the person named another one in
this session's own messages (its served id, its id at its connection or its display name, a whole
word, without case — never the model's text or a tool's output). `task`'s and `team_add`'s
`model` is refused otherwise: "Use another model for subagents only when the person names it."
No setting chooses a subagent's model; an agent definition's own `model` is not used for one.
- **Capacity, the logical restrictions only**: another model on the session's model's one-model
server is refused ("That model is on the same one-model server as this chat's model; name a
model on another server."); the session's model as its own subagent needs `parallel ≥ 2` or no
limit, else "This model serves one request at a time: name another model, on another server,
for subagents." — and nothing else is picked. A member is refused for these too, and still only
waits for a full model.
- **The offer**: the `agents {min, max}` setting is shown as **Subagents** (**Min**, **Max**;
`loop.json` `subagents.setting`) — one range from 1 to the cap with two handles, and a hint that
the numbers count the session's own agent: 1 is the agent alone, Max 4 the agent and up to three
subagents — and with it the team tools, when subagents are enabled and at
least one model could serve: the session's, with the capacity, or any usable model off its
one-model server. `context.note.team_own` tells the model, and `subagents.setting.own` the
person, when the session's model cannot serve subagents itself.
- `task`'s and `team_add`'s `model` descriptions, `task`'s description (a line on the model),
`models/schema.json` capacity texts, `team.md` §2, §4, §8. Conformance area **`subagents`**.
## 3.2.0 ## 3.2.0
Written ahead of both implementations; both carry it in the same round. Written ahead of both implementations; both carry it in the same round.
+1 -1
View File
@@ -1 +1 @@
3.2.0 3.4.0
+87 -5
View File
@@ -65,6 +65,9 @@ change to the protocol is a change here first, then in both projects.
| `rewind` | `_lembas/session/rewind` | | `rewind` | `_lembas/session/rewind` |
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` | | `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event | | `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
| `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` |
| `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) |
| `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` |
## Standard ACP, as used here ## Standard ACP, as used here
@@ -245,11 +248,64 @@ with options `once` (allow_once), `session` (allow_always, absent where "always"
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a
person may also answer at the terminal (a shared session), so the client does not time it out. person may also answer at the terminal (a shared session), so the client does not time it out.
`job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card `job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card
can say which — a background subagent may ask while the session itself is idle. can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0):
`{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) —
the card names it; the answers are the same as for the main agent's own call.
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
optional `_meta.lembas.command` (the corrected command line, `once` only) or optional `_meta.lembas.command` (the corrected command line, `once` only) or
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial. `_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
## Subagents in an agent chat (spec 3.4.0)
**An agent chat's main agent is the CLI on the device.** Its subagents are the device's: it starts
them locally, on the models of the server it is logged in to, under every rule of `subagents.md` —
the model rules (§2–§3), Min/Max, slots, permissions only ever narrowed, request options, and a
call nobody in the tree can allow going to the person. The server runs no subagent of its own for
an agent chat and offers its own subagent tool to no agent chat; what it does is carry the setting
down and the tree up. A device without the capabilities `agents` and `tree` gets neither and runs no
subagents for the chat (the chat says so beside the setting).
**What goes down, with every turn.**
- `agents: { min, max }` in `_meta.lembas` of `session/new`, of **every** `session/prompt`, and of
`_lembas/configure` when the person changes it between turns — the chat's setting as the server
keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the
session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and
nothing else reads the device's own configuration for the chat. Absent (an older server, or a
client that is not LLeMbas): the device's own setting, as before 3.4.0. A value the device's own global cap is under is read as `team.md` §2
says (lowered, never refused).
- `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the
device offers none whatever `agents` says.
- **What the person named** (`subagents.md` §2): the person's words reach the device as the turns'
prompt texts, so the device judges a named model from them as in its own sessions — every
`session/prompt` without `machine`; never a `machine` turn, a `_lembas/steer` from the harness, or
a tool's output. A chat continued on a device that has not seen its earlier turns gets them in
`_meta.lembas.said: string[]`, the person's earlier messages of the chat, oldest first (on
`session/new` only); the device counts them as typed in the session.
- The models: the device's subagents may run on any model of the person's (`/v1/models` as the
device's token sees it), as in its own sessions.
**What comes up.**
- The tree, as it changes: the `team` event (`{ tree }`, `subagents.md` §12's data, at most four a
second), and on request `_lembas/team` (the tree and the whole room). Every agent's `progress`
is in it; the main agent's own also as the `phase` event (capability `progress`).
- Approvals: a subagent's call that only the person can allow is an ordinary
`session/request_permission` with `_meta.lembas.subagent: { id, name, position }`; the server
shows it as its usual approval card naming the subagent, in every client. Requests the main agent
decides (`team_request` / `team_decide`) never reach the server as approvals: they are room
messages.
- Findings and transcripts on request: `_lembas/team/findings`, `_lembas/team/transcript` (live and
ended subagents, read-only — the link has no method that writes into a subagent's session).
- The person stopping a subagent: `_lembas/team/stop`.
**What the server keeps.** The latest tree with the chat (so a client opening the chat draws it at
once, and after the device has gone), and the room as `_lembas/team` last answered. Findings and
transcripts stay on the device; a client asking for one while the device is not linked is told it
is on the device, and gets it once the device links again. Over `/api/v2` the chat's stream carries
the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for
a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike.
## `_lembas/*` methods ## `_lembas/*` methods
Direction is who sends it. "→ result" marks a request; the others are notifications. Direction is who sends it. "→ result" marks a request; the others are notifications.
@@ -275,7 +331,7 @@ client hears the new limits.
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`. `{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`, `event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`, `tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `mode`, `model`, `flash`, `steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `phase`, `team`, `mode`, `model`, `flash`,
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on: `setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
| Event | Fields | | Event | Fields |
@@ -291,6 +347,8 @@ client hears the new limits.
| `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended | | `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended |
| `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows | | `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows |
| `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel | | `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel |
| `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged |
| `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so |
### `_lembas/directories` — client → agent → result ### `_lembas/directories` — client → agent → result
@@ -301,7 +359,7 @@ Only on the link (an error on `serve --stdio`).
### `_lembas/configure` — client → agent → result ### `_lembas/configure` — client → agent → result
Params `{ sessionId, model?: <instance's name>, effort?: <level> }` between prompts (a level as in `session/new`). Result Params `{ sessionId, model?: <instance's name>, effort?: <level>, agents?: { min, max } }` between prompts (a level as in `session/new`; `agents` with capability `agents`, spec 3.4.0). Result
`{ model: <ref>, effort }`. Emits a `model` event when something changed. `{ model: <ref>, effort }`. Emits a `model` event when something changed.
### `_lembas/steer` — client → agent → result ### `_lembas/steer` — client → agent → result
@@ -319,9 +377,9 @@ with the message in the store; `attachments`, and the text without them) or `{ r
### `_lembas/session/status` — client → agent → result ### `_lembas/session/status` — client → agent → result
Params `{ sessionId }`. Result Params `{ sessionId }`. Result
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs? }` `{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs?, progress?, tree? }`
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it — `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
without waiting for the next change; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last without waiting for the next change; `progress` the main agent's progress object or `null` (capability `progress`), and `tree` the subagent tree or `null` when the session has none (capability `tree`), for the same reason; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T: one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
`history`; T unknown → send the prompt again (safe: the device deduplicates). `history`; T unknown → send the prompt again (safe: the device deduplicates).
@@ -362,6 +420,30 @@ with its output — so a client can show the subagent as a panel and follow it w
`job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as `job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as
its session row on the device: it is kept with the parent session and deleted with it. its session row on the device: it is kept with the parent session and deleted with it.
### `_lembas/team` — client → agent → result (capability `tree`)
Params `{ sessionId, since?: number }`. Result `{ tree, room }`: the subagent tree (`subagents.md`
§12) and the room's messages numbered above `since` (default 0), every one — the person sees the
whole room — each `{ n, from, to, recipients, text, time, report, kind }` with `kind` one of
`message`, `report`, `assignment`, `request`, `decision`. `tree` is `null` for a session with no
subagents. For a session held by a terminal, answered through the hub, as `status` is.
### `_lembas/team/transcript` — client → agent → result (capability `tree`)
Params `{ sessionId, id }` — a subagent's id (`s1`), live or ended. Result `{ id, state, turns }` in
the shape of `_lembas/session/history`'s `turns`: its conversation so far, **read-only** — the link
has no method to write into it. A client following it live refetches on the `team` event and draws
the `sub_tool` events meanwhile. `-32602` for an id the session never had.
### `_lembas/team/findings` — client → agent → result (capability `tree`)
Params `{ sessionId, id }`. Result `{ id, findings: [{ n, text, time }] }`, ended subagents' too.
### `_lembas/team/stop` — client → agent → result (capability `tree`)
Params `{ sessionId, id }`: the person stops a subagent and its subtree (`subagents.md` §10), said to
the main agent as a note. Result `{ stopped: [id, …] }`; `-32602` for an id that is not live.
### `_lembas/session/delete` — client → agent → result ### `_lembas/session/delete` — client → agent → result
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
+433
View File
@@ -0,0 +1,433 @@
{
"about": "Delegation (subagents.md §7, spec 3.4.0): a subagent's mode and request options, and who decides its calls. The project root is /project, nothing named exists on disk, and only the default rules apply (permission/defaults.json). `add` — team_add's mode check for a parent in `parent_mode` with `mode` and `request` as given → `{result, mode}`: the refusal word for word, or null and the mode the subagent gets. `call` — a subagent in `mode` with `request` options and `grants` (rules {tool, pattern} the main agent allowed) under a main agent in `main_mode`, calling `call` (`bash` with a `line`, or a file tool with a `path` relative to the root) → `{answer, text?}`: `run`, `person` (an approval card to the person), or `refused` with its text, word for word — except where the case says `own_reason` (a hardline's, a mode's or the unattended refusal's reason is each project's), when only the answer is compared. `unattended`: nobody can answer. `request` — team_request for `tool` by a subagent with `request` options, its first → `{result}`. `switch` — the main agent's mode changes from `main_from` to `main_to` with these subagents (each with its parent and mode) → each one's mode after. LLeMbas: services/team.py; CLI: team/delegation.ts.",
"cases": [
{
"about": "No mode given: the parent's.",
"op": "add",
"parent_mode": "edit",
"expect": {
"result": null,
"mode": "edit"
}
},
{
"about": "Stricter is allowed.",
"op": "add",
"parent_mode": "edit",
"mode": "manual",
"expect": {
"result": null,
"mode": "manual"
}
},
{
"about": "Looser is refused, naming the parent's mode.",
"op": "add",
"parent_mode": "manual",
"mode": "edit",
"expect": {
"result": "A subagent's mode cannot be looser than its parent's (manual).",
"mode": null
}
},
{
"about": "Plan is the strictest: anything else under it is looser.",
"op": "add",
"parent_mode": "plan",
"mode": "auto",
"expect": {
"result": "A subagent's mode cannot be looser than its parent's (plan).",
"mode": null
}
},
{
"about": "Request options with the parent's own mode: nothing to ask for.",
"op": "add",
"parent_mode": "auto",
"mode": "auto",
"request": [
"write"
],
"expect": {
"result": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
"mode": null
}
},
{
"about": "Request options with no mode given (the parent's): refused the same way.",
"op": "add",
"parent_mode": "edit",
"request": [
"execute"
],
"expect": {
"result": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
"mode": null
}
},
{
"about": "Request options with a stricter mode.",
"op": "add",
"parent_mode": "edit",
"mode": "manual",
"request": [
"write",
"execute"
],
"expect": {
"result": null,
"mode": "manual"
}
},
{
"about": "An unknown kind is refused, naming it.",
"op": "add",
"parent_mode": "edit",
"mode": "manual",
"request": [
"delete"
],
"expect": {
"result": "delete is not a request option: use read, write or execute.",
"mode": null
}
},
{
"about": "An empty list of request options is no options, and needs nothing.",
"op": "add",
"parent_mode": "edit",
"request": [],
"expect": {
"result": null,
"mode": "edit"
}
},
{
"about": "All in edit: an edit inside the project runs.",
"op": "call",
"main_mode": "edit",
"mode": "edit",
"call": {
"tool": "edit",
"path": "src/a.ts"
},
"expect": {
"answer": "run"
}
},
{
"about": "All in edit: bash that edit mode asks goes to the person — the main agent could not allow it either.",
"op": "call",
"main_mode": "edit",
"mode": "edit",
"call": {
"tool": "bash",
"line": "git push origin main"
},
"expect": {
"answer": "person"
}
},
{
"about": "All in manual: an edit goes to the person.",
"op": "call",
"main_mode": "manual",
"mode": "manual",
"call": {
"tool": "edit",
"path": "src/a.ts"
},
"expect": {
"answer": "person"
}
},
{
"about": "Narrowed with no request options: what the main agent could allow is refused.",
"op": "call",
"main_mode": "edit",
"mode": "manual",
"call": {
"tool": "edit",
"path": "src/a.ts"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, and you may not ask for it: tell your parent what you need."
}
},
{
"about": "Narrowed with the kind in its options: it asks the main agent.",
"op": "call",
"main_mode": "edit",
"mode": "manual",
"request": [
"write"
],
"call": {
"tool": "edit",
"path": "src/a.ts"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
}
},
{
"about": "Narrowed, the kind not in its options.",
"op": "call",
"main_mode": "auto",
"mode": "edit",
"request": [
"write"
],
"call": {
"tool": "bash",
"line": "git push origin main"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, and you may not ask for it: tell your parent what you need."
}
},
{
"about": "Narrowed, execute in its options: asks the main agent.",
"op": "call",
"main_mode": "auto",
"mode": "edit",
"request": [
"execute"
],
"call": {
"tool": "bash",
"line": "git push origin main"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
}
},
{
"about": "A grant from the main agent lets the call run.",
"op": "call",
"main_mode": "auto",
"mode": "edit",
"request": [
"execute"
],
"grants": [
{
"tool": "bash",
"pattern": "git push *"
}
],
"call": {
"tool": "bash",
"line": "git push origin main"
},
"expect": {
"answer": "run"
}
},
{
"about": "A grant for another pattern does not.",
"op": "call",
"main_mode": "auto",
"mode": "edit",
"request": [
"execute"
],
"grants": [
{
"tool": "bash",
"pattern": "git push *"
}
],
"call": {
"tool": "bash",
"line": "git commit -m x"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
}
},
{
"about": "A grant never reaches past the main agent's own mode: what it would have to ask goes to the person.",
"op": "call",
"main_mode": "edit",
"mode": "manual",
"request": [
"execute"
],
"grants": [
{
"tool": "bash",
"pattern": "git push *"
}
],
"call": {
"tool": "bash",
"line": "git push origin main"
},
"expect": {
"answer": "person"
}
},
{
"about": "Reading .env asks in every mode: it goes to the person.",
"op": "call",
"main_mode": "edit",
"mode": "edit",
"call": {
"tool": "read",
"path": ".env"
},
"expect": {
"answer": "person"
}
},
{
"about": "Reading .env under an auto main agent: a narrowed subagent with read in its options asks it.",
"op": "call",
"main_mode": "auto",
"mode": "manual",
"request": [
"read"
],
"call": {
"tool": "read",
"path": ".env"
},
"expect": {
"answer": "refused",
"text": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows."
}
},
{
"about": "Plan mode's refusal is final: request options do not reach past it.",
"op": "call",
"main_mode": "auto",
"mode": "plan",
"request": [
"execute"
],
"call": {
"tool": "bash",
"line": "rm -rf build"
},
"own_reason": true,
"expect": {
"answer": "refused"
}
},
{
"about": "What the rules allow runs in every mode.",
"op": "call",
"main_mode": "edit",
"mode": "plan",
"call": {
"tool": "bash",
"line": "git status"
},
"expect": {
"answer": "run"
}
},
{
"about": "The hardline refuses in every mode, auto included.",
"op": "call",
"main_mode": "auto",
"mode": "auto",
"call": {
"tool": "bash",
"line": "rm -rf /"
},
"own_reason": true,
"expect": {
"answer": "refused"
}
},
{
"about": "Unattended: what would go to the person is refused.",
"op": "call",
"main_mode": "edit",
"mode": "edit",
"unattended": true,
"call": {
"tool": "bash",
"line": "git push origin main"
},
"own_reason": true,
"expect": {
"answer": "refused"
}
},
{
"about": "team_request: a tool whose risk class is not in the options is refused, naming them.",
"op": "request",
"request": [
"write"
],
"tool": "bash",
"expect": {
"result": "You may not ask for bash: your request options are write."
}
},
{
"about": "team_request: a kind in the options is asked, with the request's id.",
"op": "request",
"request": [
"write",
"execute"
],
"tool": "bash",
"expect": {
"result": "Asked the main agent (r1): you are told its decision. Carry on with what you can meanwhile."
}
},
{
"about": "The main agent made stricter: every subagent looser than its parent is made as strict as it, down the tree; a stricter one keeps its own.",
"op": "switch",
"agents": [
{
"name": "api",
"parent": "lead",
"mode": "auto"
},
{
"name": "tests",
"parent": "api",
"mode": "edit"
},
{
"name": "docs",
"parent": "lead",
"mode": "plan"
}
],
"main_from": "auto",
"main_to": "manual",
"expect": {
"api": "manual",
"tests": "manual",
"docs": "plan"
}
},
{
"about": "The main agent made looser: no subagent changes.",
"op": "switch",
"agents": [
{
"name": "api",
"parent": "lead",
"mode": "edit"
}
],
"main_from": "edit",
"main_to": "auto",
"expect": {
"api": "edit"
}
}
]
}
+333
View File
@@ -0,0 +1,333 @@
{
"about": "Progress (progress.md, spec 3.4.0): the text and the line every client draws for a progress object. `progress`: the object; `now`: the client's clock (Unix ms). Expect `text` — the phase's text from loop.json progress.texts, its fields filled (numbers written plainly: integers as they are, a rate with one decimal) — and `line` — `{{text}} · {{elapsed}}` with elapsed = now − since in whole seconds (5s, 1m 05s, 1h 02m), or the text alone for stopped and failed. retrying's `in` is counted down: in − floor((now − since) / 1000), never below 0. LLeMbas: services/progress.py; CLI: session/progress.ts.",
"cases": [
{
"about": "Queued, nothing more known.",
"progress": {
"phase": "queued",
"since": 1762790400000
},
"now": 1762790405000,
"expect": {
"text": "Queued",
"line": "Queued · 5s"
}
},
{
"about": "Queued behind two turns.",
"progress": {
"phase": "queued",
"since": 1762790400000,
"ahead": 2
},
"now": 1762790465000,
"expect": {
"text": "Queued: 2 ahead",
"line": "Queued: 2 ahead · 1m 05s"
}
},
{
"about": "A one-request server answering another request.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "Qwen 27B",
"reason": "busy"
},
"now": 1762790412400,
"expect": {
"text": "Waiting for Qwen 27B: it serves one request at a time and is answering another",
"line": "Waiting for Qwen 27B: it serves one request at a time and is answering another · 12s"
}
},
{
"about": "Every slot taken.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "coder",
"reason": "slots"
},
"now": 1762790401000,
"expect": {
"text": "Waiting for coder: all its slots are in use",
"line": "Waiting for coder: all its slots are in use · 1s"
}
},
{
"about": "Another model on the one-model server.",
"progress": {
"phase": "waiting_for_model",
"since": 1762790400000,
"model": "coder",
"reason": "group"
},
"now": 1762790400000,
"expect": {
"text": "Waiting for coder: its server holds another model now",
"line": "Waiting for coder: its server holds another model now · 0s"
}
},
{
"about": "Loading.",
"progress": {
"phase": "loading_model",
"since": 1762790400000,
"model": "Gemma 12B"
},
"now": 1762794125000,
"expect": {
"text": "Loading Gemma 12B",
"line": "Loading Gemma 12B · 1h 02m"
}
},
{
"about": "Reading the prompt: the percentage is floored.",
"progress": {
"phase": "reading_prompt",
"since": 1762790400000,
"done": 1999,
"total": 3000
},
"now": 1762790402000,
"expect": {
"text": "Reading the prompt: 1999 / 3000 tokens (66%)",
"line": "Reading the prompt: 1999 / 3000 tokens (66%) · 2s"
}
},
{
"about": "Reading the prompt with no counts from the server.",
"progress": {
"phase": "reading_prompt",
"since": 1762790400000
},
"now": 1762790402000,
"expect": {
"text": "Reading the prompt",
"line": "Reading the prompt · 2s"
}
},
{
"about": "Thinking.",
"progress": {
"phase": "thinking",
"since": 1762790400000
},
"now": 1762790459999,
"expect": {
"text": "Thinking",
"line": "Thinking · 59s"
}
},
{
"about": "Writing, the rate with one decimal.",
"progress": {
"phase": "writing",
"since": 1762790400000,
"rate": 12.46
},
"now": 1762790404000,
"expect": {
"text": "Writing: 12.5 tok/s",
"line": "Writing: 12.5 tok/s · 4s"
}
},
{
"about": "Writing before a rate is measured.",
"progress": {
"phase": "writing",
"since": 1762790400000
},
"now": 1762790400400,
"expect": {
"text": "Writing",
"line": "Writing · 0s"
}
},
{
"about": "A tool with what it does.",
"progress": {
"phase": "running_tool",
"since": 1762790400000,
"tool": "bash",
"what": "bun test"
},
"now": 1762790461000,
"expect": {
"text": "Running bash: bun test",
"line": "Running bash: bun test · 1m 01s"
}
},
{
"about": "A tool without a title.",
"progress": {
"phase": "running_tool",
"since": 1762790400000,
"tool": "recall"
},
"now": 1762790401000,
"expect": {
"text": "Running recall",
"line": "Running recall · 1s"
}
},
{
"about": "An approval, naming the tool.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "approval",
"tool": "bash"
},
"now": 1762790430000,
"expect": {
"text": "Waiting for you: allow bash?",
"line": "Waiting for you: allow bash? · 30s"
}
},
{
"about": "An approval with no tool named.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "approval"
},
"now": 1762790400000,
"expect": {
"text": "Waiting for you: an approval",
"line": "Waiting for you: an approval · 0s"
}
},
{
"about": "A question.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "question"
},
"now": 1762790403000,
"expect": {
"text": "Waiting for you: a question",
"line": "Waiting for you: a question · 3s"
}
},
{
"about": "The plan.",
"progress": {
"phase": "waiting_for_person",
"since": 1762790400000,
"what": "plan"
},
"now": 1762790403000,
"expect": {
"text": "Waiting for you: the plan",
"line": "Waiting for you: the plan · 3s"
}
},
{
"about": "Subagents.",
"progress": {
"phase": "waiting_for_subagents",
"since": 1762790400000,
"running": 3
},
"now": 1762790490000,
"expect": {
"text": "Waiting for subagents: 3 running",
"line": "Waiting for subagents: 3 running · 1m 30s"
}
},
{
"about": "Compacting.",
"progress": {
"phase": "compacting",
"since": 1762790400000
},
"now": 1762790407000,
"expect": {
"text": "Compacting the conversation",
"line": "Compacting the conversation · 7s"
}
},
{
"about": "Retrying: `in` counts down from since.",
"progress": {
"phase": "retrying",
"since": 1762790400000,
"reason": "502 Bad Gateway",
"in": 10,
"attempt": 2
},
"now": 1762790403000,
"expect": {
"text": "Retrying in 7 s: 502 Bad Gateway",
"line": "Retrying in 7 s: 502 Bad Gateway · 3s"
}
},
{
"about": "Retrying: never below 0.",
"progress": {
"phase": "retrying",
"since": 1762790400000,
"reason": "overloaded",
"in": 2
},
"now": 1762790405000,
"expect": {
"text": "Retrying in 0 s: overloaded",
"line": "Retrying in 0 s: overloaded · 5s"
}
},
{
"about": "Stopped draws no elapsed time.",
"progress": {
"phase": "stopped",
"since": 1762790400000
},
"now": 1762790405000,
"expect": {
"text": "Stopped",
"line": "Stopped"
}
},
{
"about": "Stopped by the person.",
"progress": {
"phase": "stopped",
"since": 1762790400000,
"reason": "person"
},
"now": 1762790405000,
"expect": {
"text": "Stopped: by you",
"line": "Stopped: by you"
}
},
{
"about": "Stopped at the step limit.",
"progress": {
"phase": "stopped",
"since": 1762790400000,
"reason": "steps"
},
"now": 1762790400000,
"expect": {
"text": "Stopped: step limit",
"line": "Stopped: step limit"
}
},
{
"about": "Failed.",
"progress": {
"phase": "failed",
"since": 1762790400000,
"reason": "connection refused"
},
"now": 1762790405000,
"expect": {
"text": "Failed: connection refused",
"line": "Failed: connection refused"
}
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+230 -15
View File
@@ -34,7 +34,7 @@
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.", "about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
"max": 4 "max": 4
}, },
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). A team's members are not refused there: they wait for a slot (team.md, Capacity).", "capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). Spec 3.3.0: which model a subagent may run on at all, and the refusals for the session's own one-model server and a one-request model, are subagents.md and `subagents` below, checked first. A team's members are not refused for a full model: they wait for a slot (team.md, Capacity).",
"jobs": { "jobs": {
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).", "about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
"marks": { "marks": {
@@ -61,8 +61,30 @@
}, },
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest." "lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
}, },
"subagents": {
"about": "Spec 3.3.0 (subagents.md): which model a subagent — a task subagent or a team member — runs on, and when subagents are offered. The session's own model unless the person named another in this session; no setting chooses one. The texts are said word for word, checked in this order: not_named, same_group, one_request. conformance/subagents.json.",
"texts": {
"not_named": "Use another model for subagents only when the person names it.",
"same_group": "That model is on the same one-model server as this chat's model; name a model on another server.",
"one_request": "This model serves one request at a time: name another model, on another server, for subagents."
},
"named": {
"about": "A model is named when its served id, its id at its connection or its display name occurs as a whole word, without case, in a message the person typed in this session (never model text, tool output, files, notes, marked messages or reports; a compacted message still counts). Before the name: not a letter, digit, _, -, /, . or :. After it: not a letter, digit, _, - or /, nor a . or : followed by a letter or digit. White space in a display name matches any run of white space.",
"before": "[\\p{L}\\p{N}_\\-/.:]",
"after": "[\\p{L}\\p{N}_\\-/]|[.:][\\p{L}\\p{N}]"
},
"setting": {
"about": "How the agents {min, max} setting (team.md §2) is shown to a person: one range from 1 to the cap with two handles, `min` and `max` naming them; `label` beside it, `hint` (or `short` where one line has no room) saying what the numbers count — the session's own agent included, so 1 is that agent alone and Max 4 is it and up to 3 subagents; and `own` beside it when the session's model cannot serve subagents itself (subagents.md §4).",
"label": "Subagents",
"min": "Min",
"max": "Max",
"hint": "The count includes this agent: 1 is this agent alone, with no subagents; Max 4 is this agent and up to 3 subagents.",
"short": "1 is this agent alone",
"own": "This model serves one request at a time: subagents need another model, on another server, named in the chat."
}
},
"team": { "team": {
"about": "Teams (spec 3.2.0, team.md): a lead and the members it adds, who talk in the team's room. The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json.", "about": "Teams (spec 3.2.0, team.md; which model a member runs on and when a team is offered: spec 3.3.0, subagents.md; the main agent, the tree, permissions and a subagent's life: spec 3.4.0, subagents.md §5–§13). The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json, conformance/delegation.json.",
"agents": { "agents": {
"about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).", "about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).",
"min": 1, "min": 1,
@@ -70,20 +92,27 @@
"cap": 32 "cap": 32
}, },
"names": { "names": {
"about": "A member's name: unique in the team; `lead` is the lead's, `all` addresses everyone.", "about": "A subagent's name: unique among the subagents that have not ended; `lead` is the main agent's, `all` addresses everyone, `parent` (3.4.0) the sender's parent.",
"pattern": "^[a-z][a-z0-9-]{0,31}$", "pattern": "^[a-z][a-z0-9-]{0,31}$",
"lead": "lead", "lead": "lead",
"reserved": [ "reserved": [
"all", "all",
"lead" "lead",
] "parent"
],
"parent": "parent",
"id_prefix": "s"
}, },
"marks": { "marks": {
"message": "[Team message from {{from}} to {{to}}:]", "message": "[Team message from {{from}} to {{to}}:]",
"report": "[Team report from {{from}} to {{to}}:]" "report": "[Team report from {{from}} to {{to}}:]",
"assignment": "[Team assignment from {{from}} to {{to}}:]",
"request": "[Team request {{id}} from {{from}} to {{to}}:]",
"decision": "[Team decision on {{id}} from {{from}} to {{to}}:]",
"context_full": "[Team notice to {{to}}: your context is full]"
}, },
"unavailable": { "unavailable": {
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member.", "about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member. one_request: no model can serve as a subagent any more (subagents.md §4) — the session's model's capacity changed after the prompt was made.",
"one_agent": "The person allows one agent in this chat: there is no team. Do the work yourself.", "one_agent": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"one_request": "This model serves one request at a time now, so there is no team. Do the work yourself." "one_request": "This model serves one request at a time now, so there is no team. Do the work yourself."
}, },
@@ -131,25 +160,106 @@
"not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.", "not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.",
"nobody_to_stop": "No member is under you: there is nobody to stop.", "nobody_to_stop": "No member is under you: there is nobody to stop.",
"no_report": "(no report)", "no_report": "(no report)",
"ended": "[ended: {{reason}}]" "ended": "[ended: {{reason}}]",
"only_main": "Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).",
"not_manager": "{{name}} is not a sub-manager: place a subagent under the main agent (lead) or under one added with lead: true.",
"wider": "A subagent's mode cannot be looser than its parent's ({{parent_mode}}).",
"request_not_narrower": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
"request_kind": "{{kind}} is not a request option: use read, write or execute.",
"requestable": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows.",
"not_delegated": "Your mode does not allow this, and you may not ask for it: tell your parent what you need.",
"not_requestable": "You may not ask for {{tool}}: your request options are {{kinds}}.",
"requested": "Asked the main agent ({{id}}): you are told its decision. Carry on with what you can meanwhile.",
"no_request": "No open request {{id}}.",
"decided": "{{decision}} {{id}} for {{name}}.",
"allowed": "Allowed",
"refused": "Refused",
"decision_allowed": "Allowed: {{tool}} {{pattern}}. Try it again now.",
"decision_refused": "Refused: {{tool}} {{pattern}}.",
"no_parent": "You are the main agent: you have no parent.",
"finished": "Reported to {{to}}. Your task is done: stop here.",
"has_ended": "{{name}} has ended: replace it (team_replace) or add another.",
"cycle": "{{name}} cannot go under {{parent}}: that is under it.",
"assigned": "Assigned to {{name}}: it is told now.",
"replaced": "Replaced {{name}}: {{id}} takes its place, briefed with its task and findings. The team is {{size}} of {{max}} agents.",
"replace_brief": "{{task}}\n\nYou take over from {{name}} ({{id}}), which ended. What it found:\n{{findings}}",
"no_findings": "(no findings)",
"noted": "Noted (finding {{n}}).",
"not_yours_findings": "You may read only your own findings and those of the subagents under you.",
"context_full": "Save what you found with team_findings, then tell your parent with team_send that you are gone and what is left undone. This is your last turn: at most {{steps}} steps.",
"gone": "{{name}} ({{id}}) ran out of context and has ended. Its findings: team_findings with of {{name}}.",
"request_body": "{{tool}} {{pattern}} — {{why}}"
}, },
"read": { "read": {
"about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown.", "about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown. Spec 3.4.0: the row adds the id, the mode (with request options) and the context use; open slots follow the tree, ended subagents after them.",
"tree": "Team ({{size}} of {{max}} agents):", "tree": "Team ({{size}} of {{max}} agents):",
"agent": "{{indent}}- {{name}} — {{role}} · {{state}} · {{model}} · {{last}}", "agent": "{{indent}}- {{name}} ({{id}}) — {{role}} · {{state}} · {{model}} · {{mode}} · {{context}} · {{last}}",
"lead_role": "leads the team", "lead_role": "the main agent",
"states": { "states": {
"working": "working", "working": "working",
"waiting": "waiting for a slot", "waiting": "waiting for a slot",
"finished": "finished", "finished": "finished",
"idle": "idle" "idle": "idle",
"paused": "paused: context full",
"done": "done",
"out_of_context": "out of context",
"stopped": "stopped"
}, },
"active": "active now", "active": "active now",
"room": "Room since #{{since}}:", "room": "Room since #{{since}}:",
"message": "#{{n}} {{from}} → {{to}}: {{text}}", "message": "#{{n}} {{from}} → {{to}}: {{text}}",
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}", "report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
"none": "No messages since #{{since}}.", "none": "No messages since #{{since}}.",
"more": "More after #{{last}}: team_read with since {{last}}." "more": "More after #{{last}}: team_read with since {{last}}.",
"slots": "Open slots: {{n}} — fill them with team_add.",
"ended": "Ended:",
"ended_agent": "- {{name}} ({{id}}) — {{role}} · {{state}} · {{findings}} findings",
"context": "{{percent}}% context",
"request": "+ {{kinds}}"
},
"main_only": {
"about": "Spec 3.4.0 (subagents.md §6, §8): the tools only the main agent has; a subagent's call to one, or to task, answers texts.only_main.",
"tools": [
"team_add",
"team_assign",
"team_decide",
"team_replace",
"team_stop"
]
},
"context": {
"about": "Spec 3.4.0 (subagents.md §9): a subagent does not compact. When its context reaches pause_at of its model's window it is paused and given one final turn of at most final_steps steps, whose user message is the context_full mark and texts.context_full.",
"pause_at": 0.85,
"final_steps": 8
},
"request": {
"about": "Spec 3.4.0 (subagents.md §7): the kinds a request option may name — risk classes — and the ids of requests, r1, r2, … per session.",
"kinds": [
"read",
"write",
"execute"
],
"id_prefix": "r"
},
"whoami": {
"about": "Spec 3.4.0 (subagents.md §8): team_whoami's answer, one line each; `request` only with request options, `parent` only for a subagent. {{percent}} is floor(used × 100 / window).",
"lines": {
"who": "You are {{name}} ({{id}}): {{role}}.",
"parent": "Your parent: {{parent}}.",
"task": "Your task: {{task}}",
"mode": "Your mode: {{mode}}.",
"request": "You may ask the main agent for: {{kinds}} (team_request).",
"model": "Your model: {{model}}.",
"limits": "Limits: the team is {{size}} of {{max}} agents; at most {{steps}} steps a turn; {{spawn}}",
"spawn_yes": "you add subagents.",
"spawn_no": "only the main agent adds subagents.",
"context": "Context: {{used}} of {{window}} tokens ({{percent}}%)."
}
},
"findings": {
"about": "Spec 3.4.0: team_findings read's answer — the header, then one line per finding, oldest first; none: texts.no_findings after the header.",
"header": "Findings of {{name}} ({{id}}):",
"item": "{{n}}. {{text}}"
} }
}, },
"context": { "context": {
@@ -203,7 +313,7 @@
"started": "Started in: {{path}} (relative to the project root)" "started": "Started in: {{path}} (relative to the project root)"
}, },
"note": { "note": {
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own.", "about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own. Spec 3.3.0 (subagents.md §4): `team_own` after `team` (and after `team_lead`) in the same block when the session's model cannot serve subagents itself — it serves one request at a time — and another server's model could.",
"open": "<session-note>", "open": "<session-note>",
"close": "</session-note>", "close": "</session-note>",
"date": "Today is {{date}}.", "date": "Today is {{date}}.",
@@ -217,11 +327,116 @@
"personality": "The person chose a different personality:\n{{personality}}", "personality": "The person chose a different personality:\n{{personality}}",
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}", "mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
"team": "The team may have {{min}} to {{max}} agents, you included.", "team": "The team may have {{min}} to {{max}} agents, you included.",
"team_lead": "Work through a team from the start: you lead, and members do the work — at least {{members}} of them.", "team_lead": "Work through subagents from the start: you lead, and they do the work. The person opened {{members}} slots for them: fill them with team_add.",
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.", "team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
"team_own": "This model serves one request at a time, so subagents cannot run on it: they need another model, on another server, that the person names in this chat.",
"team_stopped": "The person stopped {{names}}; they have left the team.", "team_stopped": "The person stopped {{names}}; they have left the team.",
"empty": "(none)" "empty": "(none)"
}, },
"mcp_wait_ms": 3000 "mcp_wait_ms": 3000
},
"progress": {
"about": "Spec 3.4.0 (progress.md): one fixed set of phases every client shows the same way. `texts` are the shared i18n keys progress.<phase> (a variant: progress.<phase>.<variant>); `line` is drawn for every phase but stopped and failed; elapsed in whole seconds (5s, 1m 05s, 1h 02m). conformance/progress.json.",
"phases": [
"queued",
"waiting_for_model",
"loading_model",
"reading_prompt",
"thinking",
"writing",
"running_tool",
"waiting_for_person",
"waiting_for_subagents",
"compacting",
"retrying",
"stopped",
"failed"
],
"fields": {
"queued": {
"ahead": "int?"
},
"waiting_for_model": {
"model": "string",
"reason": "busy|slots|group"
},
"loading_model": {
"model": "string"
},
"reading_prompt": {
"done": "int?",
"total": "int?"
},
"thinking": {},
"writing": {
"rate": "number?"
},
"running_tool": {
"tool": "string",
"what": "string?"
},
"waiting_for_person": {
"what": "approval|question|plan",
"tool": "string?"
},
"waiting_for_subagents": {
"running": "int"
},
"compacting": {},
"retrying": {
"reason": "string",
"in": "int",
"attempt": "int?"
},
"stopped": {
"reason": "person|steps|budget?"
},
"failed": {
"reason": "string"
}
},
"texts": {
"queued": "Queued",
"queued.ahead": "Queued: {{ahead}} ahead",
"waiting_for_model": "Waiting for {{model}}: {{reason}}",
"loading_model": "Loading {{model}}",
"reading_prompt": "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
"reading_prompt.bare": "Reading the prompt",
"thinking": "Thinking",
"writing": "Writing: {{rate}} tok/s",
"writing.bare": "Writing",
"running_tool": "Running {{tool}}: {{what}}",
"running_tool.bare": "Running {{tool}}",
"waiting_for_person": "Waiting for you: {{what}}",
"waiting_for_subagents": "Waiting for subagents: {{running}} running",
"compacting": "Compacting the conversation",
"retrying": "Retrying in {{in}} s: {{reason}}",
"stopped": "Stopped",
"stopped.reason": "Stopped: {{reason}}",
"failed": "Failed: {{reason}}"
},
"reason": {
"busy": "it serves one request at a time and is answering another",
"slots": "all its slots are in use",
"group": "its server holds another model now"
},
"what": {
"approval": "allow {{tool}}?",
"approval.bare": "an approval",
"question": "a question",
"plan": "the plan"
},
"stopped_reason": {
"person": "by you",
"steps": "step limit",
"budget": "budget"
},
"line": "{{text}} · {{elapsed}}",
"no_elapsed": [
"stopped",
"failed"
],
"what_max": 80,
"pace_per_second": 4
} }
} }
+3 -3
View File
@@ -21,11 +21,11 @@
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." }, "notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
"capacity": { "capacity": {
"type": "object", "type": "object",
"description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.", "description": "How much the model's server can do at once, so a subagent on it is not started where it would evict or wait for the model already working (subagents.md §3).",
"properties": { "properties": {
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." }, "group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one, so a subagent on another model of the session's model's group is refused (spec 3.3.0, subagents.md §3). The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." }, "single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." },
"parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. A team (team.md) is offered only on a model that serves at least 2." } "parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. The session's model is its own subagents' model only when it serves at least 2; a one-request session needs a named model on another server (spec 3.3.0, subagents.md §3, §4)." }
}, },
"additionalProperties": false "additionalProperties": false
} }
+7
View File
@@ -30,6 +30,13 @@
"team_send": "allow", "team_send": "allow",
"team_read": "allow", "team_read": "allow",
"team_stop": "allow", "team_stop": "allow",
"team_whoami": "allow",
"team_finish": "allow",
"team_findings": "allow",
"team_request": "allow",
"team_decide": "allow",
"team_assign": "allow",
"team_replace": "allow",
"web_search": "allow", "web_search": "allow",
"memory": "allow", "memory": "allow",
"session_search": "allow", "session_search": "allow",
+107
View File
@@ -0,0 +1,107 @@
# Progress
Spec 3.4.0. **One way to say what a session is doing**, the same in every client — the web UI,
Desktop, the CLI, Android — and for every agent in it, the main agent and each subagent alike.
Before 3.4.0 each client invented its own subset from a free `status` text; that text stays, for
compatibility, and beside it goes a structured **progress** object with one of a fixed set of
**phases**. The texts are `loop.json` `progress`; the cases `conformance/progress.json`.
## 1. The object
```json
{ "phase": "running_tool", "since": 1762790400000, "tool": "bash", "what": "bun test" }
```
- **`phase`** — one of the thirteen names below, never another. A session doing nothing has no
progress: `null`.
- **`since`** — when this phase began, Unix time in **milliseconds** (an integer). A change of the
phase's data (tokens read, the rate) keeps `since`; a new phase, or the same phase for another
thing (another tool call, another model), starts a new one.
- The phase's **data**, as listed: every field named here, with these names and these types. A
field that is not known is left out (never `null`, never a guess), and the text says less.
## 2. The phases
In the order a turn usually meets them. `{{x}}` in a text is the field `x`; the texts are
`loop.json` `progress.texts`, word for word, and they are the shared i18n keys
`progress.<phase>` (and `progress.<phase>.<variant>`, `progress.reason.<reason>`,
`progress.what.<what>`) in every client's translations.
| Phase | Data | Text (`progress.texts`) |
|---|---|---|
| `queued` | `ahead?: int` — turns before it | `Queued` · with `ahead`: `Queued: {{ahead}} ahead` |
| `waiting_for_model` | `model: string` (its display name, else its id); `reason: "busy" \| "slots" \| "group"` | `Waiting for {{model}}: {{reason}}` |
| `loading_model` | `model: string` | `Loading {{model}}` |
| `reading_prompt` | `done: int`, `total: int` — prompt tokens processed of all | `Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)` · without `total`: `Reading the prompt` |
| `thinking` | — | `Thinking` |
| `writing` | `rate?: number` — tokens a second, as measured over this reply | `Writing: {{rate}} tok/s` · without `rate`: `Writing` |
| `running_tool` | `tool: string` (the tool's name); `what?: string` — the call's short title (the command, the path, the query), one line, at most 80 characters | `Running {{tool}}: {{what}}` · without `what`: `Running {{tool}}` |
| `waiting_for_person` | `what: "approval" \| "question" \| "plan"`; `tool?: string` (approval: the tool asked about) | `Waiting for you: {{what}}` |
| `waiting_for_subagents` | `running: int` — subagents working or waiting now | `Waiting for subagents: {{running}} running` |
| `compacting` | — | `Compacting the conversation` |
| `retrying` | `reason: string` — the server's error, one line; `in: int` — seconds until the retry, counted down by the client from `since`; `attempt?: int` | `Retrying in {{in}} s: {{reason}}` |
| `stopped` | `reason?: "person" \| "steps" \| "budget"` | `Stopped` · with `reason`: `Stopped: {{reason}}` |
| `failed` | `reason: string` — the error, one line | `Failed: {{reason}}` |
The fixed words a field stands for:
- `reason` of `waiting_for_model` (`progress.reason`): `busy` — *it serves one request at a time
and is answering another*; `slots` — *all its slots are in use*; `group` — *its server holds
another model now*.
- `what` of `waiting_for_person` (`progress.what`): `approval` — *allow {{tool}}?* (*an approval*
without `tool`); `question` — *a question*; `plan` — *the plan*.
- `reason` of `stopped` (`progress.stopped_reason`): `person` — *by you*; `steps` — *step limit*;
`budget` — *budget*.
- `percent` is `floor(done × 100 / total)`; numbers are written as integers, `rate` with one
decimal (`12.5`), in the client's own number format.
## 3. The line a client draws
**`{{text}} · {{elapsed}}`** (`progress.line`) for every phase but `stopped` and `failed`, which
draw their text alone. `elapsed` is now − `since`, written in whole seconds as a ticking clock writes durations
(`5s`, `1m 05s`, `1h 02m`), and kept moving by the client at least once a second while the
phase lasts. `retrying`'s `in` is likewise counted down from `since` (never below 0).
Every client draws the same line for the same object — the same words, from the same keys — and
its own look around it (a spinner, a colour, a bar for `reading_prompt`). Where a line has no room
(a phone's notification, a tree row) the text alone, without `· elapsed`, is enough; nothing else
is shortened.
## 4. When each phase holds
What decides the phase, so that two implementations agree on the same moment:
- `queued` — a prompt waits behind a turn that runs (the device's turn queue; the server's per
chat). `waiting_for_model` — the turn may start but its model may not take it yet: a one-request
server answering another request (`busy`), every slot of the model held (`slots`; `team.md` §8),
another model of its one-model server in use (`group`). `loading_model` — only where the
implementation is told the model is being loaded (the server sees it; a device that cannot tell
skips it).
- `reading_prompt` — the request is sent and no output has come; with `done`/`total` from the
server's prompt-progress frames (`quirks.json` `prompt-progress`), without them where none come.
- `thinking` — reasoning streams; `writing` — reply text (or a tool call's arguments) streams.
- `running_tool` — from a tool call's start to its end; with calls in parallel, the one that
started first. `waiting_for_person` — an approval card, a question or a plan to approve is open
for this agent. `waiting_for_subagents` — the agent's turn waits on subagents: a `task` call in
the foreground, or the main agent with nothing to do but its subagents' reports (`subagents.md`
§9).
- `compacting` — a compaction runs. `retrying` — a failed request will be sent again after a wait.
- `stopped` / `failed` — the turn ended other than by finishing: stopped by the person, a step limit
or a budget; failed with an error. They are the last progress of that turn and stay until the
next one begins; a turn that finished normally ends with `null`.
## 5. Where it is sent
- **`/api/v2` (LLeMbas)**: every frame of a chat's stream that carries the `status` text carries
`progress` beside it — the object, or `null` — and a frame is sent when the progress changes:
a new phase at once, a data-only change (`done`, `rate`) at most four times a second. `status`
stays as it was, its text unchanged, for clients that do not read `progress`.
- **The link (`acp.md`, capability `progress`)**: the `phase` event `{ progress, agent? }` —
`agent` the subagent's id (`subagents.md` §5) when the progress is a subagent's, absent for the
main agent's — under the same pacing; `progress` in `_lembas/session/status`, so a client
reattaching draws it at once. The device sends it; the server passes it on in `/api/v2` as its
own.
- **The subagent tree** (`subagents.md` §12): every agent carries its current `progress`.
A client that reads `progress` draws it and ignores `status`; one that does not, draws `status` as
before.
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of <!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
the mode text; LLeMbas's helper says the same. The variables are what differs per run. --> the mode text; LLeMbas's subagent says the same. The variables are what differs per run. -->
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}} You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}} Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
+14 -12
View File
@@ -1,12 +1,14 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): how to lead a team, in the system prompt whenever <!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §5–§13): how the main agent
the team tools are offered, and in a member's that may lead. Constant: the size the person leads its subagents, in the system prompt whenever the team tools are offered. Constant: the
allows comes in a session note, never here. --> size the person allows comes in a session note, never here. -->
# Leading a team # Leading subagents
You can add members to a team with team_add: agents that work in the background, each on the part of the work it owns, while you lead. A small job you may do yourself. Once you have a member you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review. You are the main agent of this session. You can add subagents with team_add: agents that work in the background, each on the part of the work it owns, while you lead. Only you add, assign, replace and stop them. A small job you may do yourself. Once you have a subagent you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one member owns a thing from start to finish. - Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one subagent owns a thing from start to finish.
- Name each member after what it owns. Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report. - Name each subagent after what it owns and give it a position (`role`). Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
- Keep the team flat while it is small — up to six or eight members, each reporting to you. Beyond that, add a lead for each area (`lead: true`), give it the area's goal, and let it build and run its own members; you then lead the leads. - Keep the tree flat while it is small — up to six or eight subagents, each reporting to you. Beyond that, add a sub-manager for each area (`lead: true`) and place that area's subagents under it (`parent`); it directs them and reports to you, and you still add and stop every one.
- Keep the team within the size the person allows, which a session note tells you. Stop a member whose part is done or no longer needed, to make room. - A subagent's mode is yours by default and never looser. Give a stricter one where the person wants less done unasked; with it you may give `request` options, and you decide its requests with team_decide — allow only what the task needs.
- Coordinate through the room: team_send to the member who owns a thing, `all` only for what everyone needs. team_read shows the team and the room when you need to look; nothing from it reaches you unasked except your members' reports and messages addressed to you. - Keep within the size the person allows, which a session note tells you; when it says slots are open, fill them. Stop a subagent whose part is done or no longer needed, to make room.
- Step in when a member is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns. - Coordinate through the room: team_send to the subagent who owns a thing, `all` only for what everyone needs. team_read shows the tree and the room when you need to look; nothing from it reaches you unasked except reports, requests and messages addressed to you.
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer: to the person, or, if you lead an area, as your report to your manager. - A subagent whose context fills saves its findings and ends. Read them with team_findings, then replace it (team_replace) if its part is not done.
- Step in when a subagent is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns (team_assign for new work).
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer to the person.
+9 -7
View File
@@ -1,14 +1,16 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): a team member's standing, said once at the end <!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §13): a subagent's standing,
of its system prompt in place of blocks/subagent.md. The variables are what differs per member; said once at the end of its system prompt in place of blocks/subagent.md. The variables are what
each stands on a line of its own, so no punctuation of theirs matters. --> differs per subagent; each stands on a line of its own, so no punctuation of theirs matters. -->
You are "{{name}}", a member of a team, and you report to "{{manager}}". You are "{{name}}" ({{id}}), a subagent of this session, and you report to "{{manager}}".
Your part: {{role}} Your position: {{role}}
The team's goal: {{goal}} The team's goal: {{goal}}
{{instructions}} {{instructions}}
You work for your manager on the part you own, and you cannot ask the person anything: ask your manager, by name, with team_send. Other members own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone on the team needs; messages to you arrive as you work. team_read shows who is on the team and the room. You work for your parent on the part you own, and you cannot ask the person anything: ask your parent with team_send to `parent`. Only the main agent (`lead`) adds, assigns and stops subagents. Other subagents own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone needs; messages to you arrive as you work. team_read shows the tree and the room; team_whoami shows who you are, your mode and how full your context is.
Your final reply is your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A message may wake you again afterwards; your next final reply is then your next report.{{read_only}} Your context is limited and you do not compact: write what you find with team_findings as you go. When your context fills you are told, and you have one last turn to save your findings and tell your parent.
When your task is done, call team_finish with your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A final reply without it is also your report, and a message may then wake you again.{{read_only}}
{{lead}} {{lead}}
@@ -0,0 +1,8 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.4.0 (subagents.md §5, §13): what a sub-manager is told, in
place of the whole of blocks/team_lead.md that a member with `lead: true` got in 3.2.0. -->
# Directing an area
You are a sub-manager: the main agent places subagents under you, and you direct them. You do not add, assign, replace or stop subagents — ask the main agent with team_send to `lead` when your area needs one more, another task for one, or one stopped. You manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
- Split your area's work among the subagents under you, tell each what it owns with team_send, and keep them from overlapping.
- Their reports come to you. Review each against what you asked — read the work itself where it matters — and send back what is not done.
- Read the findings of a subagent under you with team_findings, especially of one that ran out of context; tell the main agent whether it needs replacing.
- When your area is done, call team_finish with your report to the main agent.
+11 -4
View File
@@ -251,16 +251,23 @@
"scope": "shared", "scope": "shared",
"llembas": [], "llembas": [],
"llembas_text": "identical", "llembas_text": "identical",
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment", "why": "spec 3.2.0, 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment",
"variables": { "variables": {
"name": "the member's name in the team", "name": "the member's name in the team",
"role": "its role as team_add gave it: one line, what it owns", "role": "its position, one line (team_add's role)",
"manager": "the name of the agent that added it (`lead` for the team's lead)", "manager": "its parent: lead, or the sub-manager it was placed under",
"goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)", "goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)",
"instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md", "instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md",
"read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty", "read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty",
"lead": "for a member added with `lead: true`: the whole of blocks/team_lead.md; otherwise empty" "lead": "for a sub-manager (added with `lead: true`): the whole of blocks/team_submanager.md (spec 3.4.0; before, blocks/team_lead.md); otherwise empty",
"id": "the subagent's id (s1, s2, …; spec 3.4.0)"
} }
},
"blocks/team_submanager.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "identical",
"why": "spec 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
} }
} }
} }
+362
View File
@@ -0,0 +1,362 @@
# Subagents
Spec 3.3.0; §5–§13 spec 3.4.0. **Which model a subagent runs on, and when subagents are offered at all.** A
**subagent** is any agent a session's agent starts to work for it: a `task` subagent (in the
foreground or as a background job) and a team's member (`team.md`) alike. The same rules in a chat
on the server and in an agent session on a device, in both projects. The texts are `loop.json`
`subagents`; the cases `conformance/subagents.json`.
## 1. The word
**Subagent** is the word in every text a person or a model reads: the UI (the web UI, Desktop,
Android, the CLI, the admin pages), the prompt texts and the tool texts. A team's members are
subagents too. The word *helper* is retired. Tool names, their former names (`aliases`) and
stored identifiers do not change.
## 2. The model a subagent runs on
**The session's own model**, always — unless **the person has named another model** in this
session. Nothing else chooses one: there is **no setting** for a subagent's model anywhere — not
in a chat, not in a person's or an instance's settings, not in the configuration, and an agent
definition's own `model` (the CLI's agent files) is not used for a subagent either.
- **Named** means: in a message **the person typed** in this session, the model's served id, its
id at its connection, or its display name occurs as a whole word, letters compared without case.
Only the person's own words count — never the model's text, a tool's output, a file, a note, a
job's or a team's marked message, or a subagent's report. A message that was compacted away
still counts: what the person named stays named for the whole session.
- **A whole word**: the character before the name is not a letter, a digit, `_`, `-`, `/`, `.` or
`:`; the character after it is not a letter, a digit, `_`, `-` or `/`, nor a `.` or `:` followed
by a letter or a digit. So `coder-30b.` at the end of a sentence names `coder-30b`, and
`coder-30b-instruct` does not. Runs of white space in a display name match any run of white
space.
- The `model` parameter of `task` and of `team_add` takes a model the person may use, by the same
names. When it is **not the session's model and the person has not named it**, the call is
refused, word for word, with `subagents.texts.not_named`:
> Use another model for subagents only when the person names it.
The session's own model given as `model` is the default spelled out, and is never refused for
not being named.
- A team's members are of the lead's session: what the person named there counts for every
`team_add` in the team, a sub-manager's included. A member's default is the session's model, not
its manager's.
## 3. Capacity: the logical restrictions, and no others
What a model's server can do at once is `models/schema.json` `capacity`: `parallel` (`single_session`
being `parallel: 1`; neither said — no limit), and `group`, the server that holds one model at a
time. With **S** the session's model and **M** the subagent's, checked in this order, after
`not_named`:
1. **M is another model on S's one-model server** (both have the same `group`): refused with
`subagents.texts.same_group` — starting it would unload the lead's model:
> That model is on the same one-model server as this chat's model; name a model on another server.
2. **M is S, and S serves one request at a time** (`parallel` 1): refused with
`subagents.texts.one_request` — the subagent would take the only slot from the lead:
> This model serves one request at a time: name another model, on another server, for subagents.
There is **no fallback**: nothing else is picked for the person, ever. A model the person named,
on another server, is the way out — and a model that serves one request at a time is fine there.
These apply to `task` and to `team_add` alike, and before anything is started. Beyond them nothing
is refused for being the wrong model: a member past the free slots waits for one (`team.md` §8),
and `task` keeps its rule for a model that is already full (`loop.json` `capacity`).
## 4. When subagents are offered
The size setting `agents {min, max}` (`team.md` §2) is shown as **Subagents** — `subagents.setting`:
the label, and **Min** and **Max** for `min` and `max` (defaults 1 and 4, at most `cap` 32, kept
with the chat). **The numbers count the session's own agent**: Min 1 / Max 1 is the agent alone,
with no subagents; Max 4 is the agent and up to three subagents (`subagents.setting.hint`). A UI
shows the setting as **one range from 1 to `cap` with two handles, Min and Max** (Min never past
Max): a slider in a graphical UI, two numbers on one line in a terminal, each moved on its own. It, the team tools and `blocks/team_lead.md` are **offered** when both hold:
1. Subagents are enabled for the session (`team.md` §2, rule 1).
2. **At least one model could serve as a subagent**: S itself, when it serves two or more requests
at once (`parallel ≥ 2`, or no limit); or **any other model the person may use that is not on
S's one-model server** (no `group`, or another one) — whatever its own `parallel`.
So a session on a one-request model is offered subagents when another server has a model, and
**its first note says so** (`context.note.team_own`, after `team`): the session's model cannot
serve subagents itself, and they need a model the person names. A UI showing the setting says the
same beside it (`subagents.setting.own`). Nothing is offered when neither holds — the same as
before 3.3.0 for a single model.
What counts as **usable** is each project's: LLeMbas — the models the person may use in the chat's
picker; the CLI — the models of the server it is logged in to.
---
# Subagents v2 — the main agent and its tree
Spec 3.4.0. **How subagents are organised, what each may do, and how each ends.** The owner's model:
one permanent **main agent** per session, which alone makes subagents and places them in a tree;
permissions are inherited and never widened; a subagent is temporary and ends with its task or its
context. Sections 5–13 **supersede `team.md` where they say something different** — who adds a
subagent (§6), who stops one (§10), the states (§9), what a member is told (§13) — and keep the
rest of it: the size setting and its counting (§2–§3), the room and who receives what (§5),
delivery and waking (§7), a manager's refusals (§6 there), capacity and waiting for a slot (§8).
The tool names `team_*` stay (nothing stored or trained breaks); `task` and `report` are unchanged
for a one-off subagent. The texts are `loop.json` `team` (3.4.0 additions marked there); the cases
`conformance/team.json` (the tree, the tools, the lifecycle) and `conformance/delegation.json`
(permissions).
## 5. Words
- **The main agent** is the session's own agent: the chat's agent on LLeMbas, the session's in the
CLI. It is **permanent**: it compacts and never ends. In the room and in every tool it is called
`lead` (the name is kept from 3.2.0); a person reads it as *the main agent*.
- **A subagent** is an agent the main agent added (`team_add`). It has:
- **`id`** — `s1`, `s2`, … in the order the session's subagents were added, never reused in the
session (a replacement gets a new one);
- **`name`** — the main agent's choice (`team.names.pattern`), unique among the subagents that
have not ended, `lead`, `all` and `parent` reserved;
- **`position`** — its designation, one line, given by the main agent (`team_add`'s `role`:
*the API's tests*, *reviews the parser*);
- **`parent`** — the main agent, or a **sub-manager** the main agent placed it under;
- **`task`** — its brief, as given (and as replaced by `team_assign`);
- **`mode`** and **`request`** — its permission mode and its request options (§7);
- **`model`** — `subagents.md` §2–§3, unchanged.
- **A sub-manager** is a subagent added with `lead: true`. It **directs** the subagents the main
agent places under it — they report to it, it messages them, it reviews their work — and it does
**not** add, assign, replace or stop subagents: only the main agent does (§6). Like every manager
it does not do the work itself (`team.md` §6, unchanged).
- **The tree**: the main agent at the root, every subagent under its parent, in the order added.
**Under** an agent and **its subtree** keep their `team.md` meaning.
## 6. Only the main agent makes subagents
- **`team_add`, `team_assign`, `team_replace`, `team_stop` and `team_decide`** are the main agent's.
A subagent's call to any of them is refused, word for word, with `team.texts.only_main`:
> Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).
A subagent's tool list never has `task` (a one-off subagent is the main agent's too), and a call
to it by a former name is refused with the same text.
- **`team_add`** takes, beside 3.2.0's `name`, `role` (the position), `task`, `goal`, `lead`,
`agent`, `model`: **`parent`** — the name of a sub-manager to place it under (default: the main
agent; refused with `team.texts.not_manager` for a subagent that is not a sub-manager, and
`unknown` for no such name), **`mode`** — its mode (default: its parent's; §7), **`request`** —
its request options (§7). Its refusals, in order: `unavailable`, `only_main`, then 3.2.0's (`all_name`,
`bad_name`, `taken`, `no_goal`, `full`), then `unknown` and `not_manager` for `parent`, then the mode's (`wider`, `request_not_narrower`,
`request_kind`), then the model's (§2–§3). `parent` and the reserved names are refused as
`taken`.
- **The person** can tell the main agent to spawn — it is the main agent's call, as any — or set
**Min above 1**: then **`min − 1` slots** exist from the start of the main agent's reply (the
first one after the setting was told, `team.md` §10). A **slot** is a place in the tree for a
subagent not added yet: it is shown (§12) and `team_read` lists it, it is **not** an agent and
does not count toward the size, and each subagent added fills one. Open slots are
`max(0, min − 1 − subagents that have not ended)`. The main agent is told to use them in its
note (`context.note.team_lead`, which 3.4.0 words as slots), and is a manager from the start
(`team.md` §6).
- **Min/Max** (3.3.0) are unchanged: they count the main agent; 1 is the main agent alone. The
model rules (§2–§3) are unchanged.
## 7. Permissions: inherited, never widened
**A subagent's mode is its parent's or stricter** — `permission/modes.json` `order`, loosest first:
`auto`, `edit`, `manual`, `plan`. The main agent's mode is the session's.
- `team_add`'s and `team_assign`'s `mode`, looser than the parent's, is refused, word for word,
with `team.texts.wider`:
> A subagent's mode cannot be looser than its parent's ({{parent_mode}}).
When the parent's own mode becomes stricter during the session (the person switches the main
agent to manual), every subagent looser than its parent is made as strict as it, down the tree,
at once; a looser switch changes no subagent.
- **Request options** (`request`: a list of the risk classes `read`, `write`, `execute`) may be
given **only with a mode stricter than the parent's** — otherwise refused with
`team.texts.request_not_narrower` ("Request options go with a mode stricter than the parent's:
without one there is nothing to ask for."); an unknown kind with `team.texts.request_kind`. They
let the subagent **ask the main agent** for a call of that class that its own mode does not
allow; **the main agent is the check**.
**Who decides a subagent's call.** The hardline and the deny rules first, as for any call. Then:
1. The call is evaluated in the **subagent's mode**, with the session's rules and approvals and
the subagent's own grants (below). **Allow** — it runs. **Deny** (a rule, the hardline, plan
mode's refusal) — refused, as always.
2. **Ask** — the call is evaluated in the **main agent's mode** (the session's rules and
approvals, no grants):
- **Allow** — the main agent could allow it: the subagent was narrowed. If the call's risk class
is in its `request`, refused with `team.texts.requestable` — it asks with `team_request`, the
main agent decides with `team_decide`, and once allowed the same call runs. Otherwise refused
with `team.texts.not_delegated`:
> Your mode does not allow this, and you may not ask for it: tell your parent what you need.
- **Ask** — **nobody in the tree can allow it: it goes to the person**, as an approval card that
names the subagent (its name and position), the same card and the same answers as the main
agent's own; unattended, refused as `modes.json` `unattended` says. So in an all-Edit tree a
subagent's `bash` reaches the person — not the main agent, which could not allow it either.
- **Deny** — refused with that reason.
A sub-manager is never asked: it may not allow more than the main agent, and the main agent is
the check.
**`team_request`** (a subagent with request options; its tool list has it only then): `tool`, the
permission `pattern` it needs (as a rule writes it: `git push *`, a path) and `why`. Refused with
`team.texts.not_requestable` when the tool's risk class is not in its options. Posted to the main
agent as a request, under its own mark (`team.marks.request`), the body `team.texts.request_body`,
with an id (`r1`, `r2`, … per session); the answer `team.texts.requested` says it waits for the main agent's decision.
**`team_decide`** (the main agent): `id`, `allow` (a boolean), `note`. Allowed — the subagent gets
a **grant**: the rule `{ permission: <tool's key>, pattern, action: allow }` for itself alone, for
the rest of its life; **a grant only ever applies where the main agent's own mode would allow the
call** (step 2's evaluation, done again at the call), so it can never widen past the main agent.
Either way the subagent is told, under `team.marks.decision`: `team.texts.decision_allowed` or
`decision_refused`, then the note after a blank line. The answer to the main agent is
`team.texts.decided` (`decision` being `allowed` or `refused`). Refused:
`team.texts.no_request` for an id that is not an open request.
## 8. What each agent has
| Tools | Main agent | Sub-manager | Subagent |
|---|---|---|---|
| `team_whoami`, `team_read`, `team_send`, `team_findings` | ✓ | ✓ | ✓ |
| `team_finish` | — | ✓ | ✓ |
| `team_request` | — | with request options | with request options |
| `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide` | ✓ | — | — |
Fixed when the prompt is made (the main agent's) or when the subagent is added (`context.md` §10,
rule 4). What each does:
- **`team_whoami`** — who the caller is: `team.whoami`, one line each, in its order — id, name, position, parent, task, mode and
request options, model, the limits (the size and Max, the steps a turn may take, whether it may
add subagents) and its **context use** (tokens in its context now, of its model's window, and the
percentage). For the main agent: the same, its position *the main agent*, without the parent and task lines.
- **`team_read`** — the roster: the tree (every agent and open slot, §12's fields) and the room,
as in 3.2.0 (`team.read`; 3.4.0's row adds the id, the mode and the context use).
- **`team_send`** — a message `to` names, `all`, or **`parent`** (3.4.0): the caller's parent
(refused for the main agent with `team.texts.no_parent`). Otherwise 3.2.0.
- **`team_finish`** — `report`: the subagent's task is done. The report goes to its parent (the
report mark, as a final reply's), and the subagent **ends** (§9): its turn stops after this call,
it leaves the size, and its findings and transcript stay.
- **`team_findings`** — `write` (`text`): a finding kept for the caller, numbered from 1; `read`
(`of`: a name or an id, default the caller): the findings of the caller, or of an agent in its
subtree (the main agent: anybody's), ended ones included — as `team.findings` writes them. A
name that has been used more than once means its latest holder; an id is exact. Otherwise refused with
`team.texts.not_yours_findings`. The person reads every agent's (§12).
- **`team_assign`** — `name`, `task`, and optionally `role` (a new position), `parent` (another
sub-manager, or `lead`), `mode` (§7): a new task for a subagent that has not ended. Its task
becomes this one; it is told under `team.marks.assignment`, and a finished one is woken. Refused:
`unknown`, `team.texts.ended` for one that has ended, `not_manager`, `wider`; a move under its
own subtree with `team.texts.cycle`.
- **`team_replace`** — `name`, and optionally `task`: the subagent ends (stopped, if it has not
already), and a new one takes its place — the same name, position, parent, mode, request options
and model, a new id — briefed with `task` (default: the old task) followed by the old one's
findings (`team.texts.replace_brief`). Its subagents move under the new one. Refused: `unknown`.
- **`team_stop`** — the main agent's only (3.2.0 let a manager stop its subtree); otherwise 3.2.0.
- **`team_decide`** — §7.
## 9. A subagent's life
States (`team.read.states`): **`working`** (a turn runs), **`waiting`** (it has work and waits for
a slot, `team.md` §8), **`finished`** (its last turn ended with a final reply, which was its report;
a message wakes it — 3.2.0), **`paused`** (its context is full; its last turn runs), and the ends:
**`done`** (`team_finish`), **`out_of_context`** (its last turn after `paused` ended), **`stopped`**
(the main agent's `team_stop`, a replacement, or the person). The main agent is `working`,
`waiting` or `idle`.
**An ended subagent** — `done`, `out_of_context`, `stopped` — leaves the size and its slot frees;
its name may be used again; it keeps its id, and the tree keeps it (§12) with its findings and
transcript for the rest of the session. Messages to it by name are refused as `unknown`.
The subagents under a sub-manager that ended by itself (`done`, `out_of_context`) move under its
parent until the main agent replaces it, and then back under the replacement; a stopped one's
subtree is stopped with it (§10).
**When its context fills.** A subagent does not compact. When its context reaches
`team.context.pause_at` of its model's window (default 0.85, the fraction compaction would start
at), after the step that took it there:
1. it is **`paused`**: nothing new reaches it — a message to it is posted in the room and not
delivered; whoever replaces it reads it with `team_read`;
2. it is given **one final turn**, at most `team.context.final_steps` steps (8), whose user message
is `team.texts.context_full`, under its own mark: save what it found with `team_findings`, and
tell its parent with `team_send` that it is gone and what is left;
3. when that turn ends it is **`out_of_context`**, and its parent is sent `team.texts.gone` — then,
after a blank line, that turn's final reply if it gave one — as its report (the report mark): the name, that it ran out of context, and how to read its
findings. **The parent decides** — the main agent replaces it (`team_replace`) or uses the
findings; a sub-manager tells the main agent.
**The main agent is permanent**: it compacts as every session does (`context.md` §7) and never
ends; deleting the session ends every subagent.
**Waiting for subagents.** The main agent's turn may end while subagents work; their reports wake
it (3.2.0 §7). Its progress is `waiting_for_subagents` (`progress.md`) while its turn waits on
them — a foreground `task` — and the session shows the subagents' own phases in the tree.
## 10. Stopping
Only the main agent stops (`team_stop`, 3.2.0's refusals but `not_yours`, which no longer arises),
and **the person** (§12) — said to the main agent as a note (`context.note.team_stopped`). A
stopped subagent's subtree is stopped with it, unless the main agent moved it first. A sub-manager
that wants a subagent of its own stopped asks the main agent.
## 11. Transcripts and findings
Every subagent's conversation is kept — its brief, what it wrote, every tool call with its output —
for as long as the session: **viewable read-only by the person, live while it runs and after it
ended**. Nobody types into it; the person speaks to a subagent only through the main agent (or by
stopping it). Findings (§8) are kept the same way, readable by its parent, the main agent, and the
person.
## 12. What clients show
**The tree** — the main agent, every subagent under its parent, open slots, and ended subagents
(after the live ones, dimmed, with how each ended):
| Field | Shown as |
|---|---|
| `name`, `id`, `position` | `parser` (`s2`) — *reviews the parser* |
| `state` | `team.read.states`, its word |
| `model` | display name |
| `mode` | the mode's name, and request options as `+ write` etc. |
| `context` | `{used, window}` → `42%` (a bar where there is room) |
| `progress` | its current phase line (`progress.md` §3) |
| messages | the room's messages to and from it: how many unread by the person, the last one's first line |
- **Web UI, Desktop and the CLI** show the whole tree, the room, each subagent's findings and its
context (the CLI: `/context <name>`), and open any subagent's transcript read-only, live and after
it ended; the person can stop any subagent there.
- **Android** shows a reduced list — name, position, state, progress line, context % — and opens a
transcript read-only.
**The data**, the same in `/api/v2` (LLeMbas: a chat's stream carries the tree as it changes) and on
the link (`acp.md`, capability `tree`):
```json
{ "min": 1, "max": 4, "size": 2, "open_slots": 0,
"agents": [
{ "id": "lead", "name": "lead", "position": "the main agent", "parent": null, "state": "working",
"model": "qwen-27b", "mode": "edit", "request": [], "manager": true,
"context": { "used": 41200, "window": 131072 }, "progress": { "phase": "thinking", "since": 1762790400000 } },
{ "id": "s1", "name": "parser", "position": "reviews the parser", "parent": "lead", "state": "working",
"model": "qwen-27b", "mode": "manual", "request": ["write"], "manager": false, "task": "…",
"context": { "used": 9100, "window": 131072 }, "progress": { "phase": "running_tool", "since": 1762790401000, "tool": "read", "what": "src/parse.ts" },
"findings": 2, "messages": 3 }
],
"ended": [ { "id": "s0", "name": "scout", "position": "…", "parent": "lead", "state": "done", "ended": 1762790300000, "findings": 1 } ] }
```
## 13. What each is told
- The main agent: `blocks/team_lead.md` (3.4.0: it alone adds; slots; positions; modes and request
options; replacing a subagent that ran out of context) and the notes of `team.md` §10.
- A subagent: `blocks/team_member.md` (3.4.0: its id and position, `team_whoami`, `team_findings`,
`team_finish`, that its context is limited and it will be told when it fills); a sub-manager,
`blocks/team_submanager.md` in place of the whole lead block it got in 3.2.0.
## 14. In an agent chat
The main agent of an agent chat is the CLI on the linked device: it starts and runs the
subagents there, under every rule above. The server carries the chat's Subagents setting down with
every turn and the tree, approvals, findings and transcripts up, and offers no subagents of its own
to an agent chat. The protocol: `acp.md`, *Subagents in an agent chat* (capabilities `agents` and
`tree`).
+46 -18
View File
@@ -1,6 +1,6 @@
# Teams # Teams
Spec 3.2.0. **Subagents that work together.** A session's agent — the **lead** — may add Spec 3.2.0; the model a member runs on and when a team is offered, 3.3.0 (`subagents.md`); the main agent, the tree, permissions and a subagent's life, 3.4.0 (`subagents.md` §5–§13). **Subagents that work together.** A session's agent — the **lead** — may add
**members**, background agents that each own a part of the work, and lead them as a manager: it **members**, background agents that each own a part of the work, and lead them as a manager: it
plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other
and to their managers in the team's **room**. A member may itself lead an area with members of its and to their managers in the team's **room**. A member may itself lead an area with members of its
@@ -11,6 +11,16 @@ The tools are `tools/team_add.json`, `team_send.json`, `team_read.json`, `team_s
and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts
`prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`. `prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`.
> **Spec 3.4.0 supersedes parts of this page** (`subagents.md` §5–§13, *Subagents v2*): the lead is
> **the main agent**, and **only it adds, assigns, replaces and stops** — a member with `lead: true`
> is a **sub-manager** that directs the members placed under it (`team_add`'s `parent`) and does not
> add or stop (§4, §9 below); members have a mode no looser than their parent's and may ask the main
> agent for more (`subagents.md` §7); a member ends with `team_finish` or when its context fills
> (`subagents.md` §9); the room carries assignments, requests and decisions besides messages and
> reports; the tree goes over the link (§11). Everything else here stands as written: the size
> setting and its counting, the room and who receives what, delivery and waking, a manager's
> refusals, capacity and waiting for a slot. "Member" here and "subagent" there are the same thing.
## 1. Words ## 1. Words
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name - **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
@@ -29,6 +39,8 @@ and numbers `loop.json` `team` (and the size notes in `context.note`); the promp
## 2. The size setting, and when a team is offered ## 2. The size setting, and when a team is offered
A chat has the setting **`agents: {min, max}`** — the agents the team may have, the lead included. A chat has the setting **`agents: {min, max}`** — the agents the team may have, the lead included.
**The numbers count the chat's own agent**: `1` is that agent alone, with no subagents; `max` 4 is
that agent and up to three subagents. Nothing that shows or reads the setting counts subagents only.
- `1 ≤ min ≤ max ≤ cap`. `cap` is the instance's (LLeMbas) or the global configuration's (the CLI), - `1 ≤ min ≤ max ≤ cap`. `cap` is the instance's (LLeMbas) or the global configuration's (the CLI),
default `loop.json` `team.agents.cap` (32); a project's configuration never raises it. A value default `loop.json` `team.agents.cap` (32); a project's configuration never raises it. A value
@@ -46,23 +58,31 @@ system prompt — when both hold, and only then:
1. Subagents are enabled for the session: LLeMbas — the instance allows them and the chat's model 1. Subagents are enabled for the session: LLeMbas — the instance allows them and the chat's model
has its subagent tool; the CLI — its subagents are not switched off in the configuration. has its subagent tool; the CLI — its subagents are not switched off in the configuration.
2. **The chat's model serves at least two requests at once**: `capacity.parallel ≥ 2`, or neither 2. **At least one model could serve as a subagent** (spec 3.3.0, `subagents.md` §4): the chat's
`parallel` nor `single_session` is set (a hosted API; `models/schema.json`). model, when it serves at least two requests at once (`capacity.parallel ≥ 2`, or neither
`parallel` nor `single_session` is set — a hosted API; `models/schema.json`); or any other model
the person may use that is not on the chat's model's one-model server (`capacity.group`).
The setting is shown as **Subagents**, with **Min** and **Max** (`loop.json` `subagents.setting`):
**one range from 1 to `cap` with two handles**, Min and Max, where Min never passes Max — a slider in
a graphical UI; in a terminal, the two numbers on one line, each moved on its own. Beside it, its
hint (`subagents.setting.hint`, or `short` where a line has no room): the count includes the agent,
and 1 is the agent alone. Never *fewest* / *most*, and never a count of subagents alone.
Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes
a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is
listed, and the session's `task` works as before. listed, and the session's `task` works as before.
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and the model still While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and a model could
serves two at once — or, for the lead, when its team still has a member (so a lead whose person still serve as a subagent (rule 2) — or, for the lead, when its team still has a member (so a lead whose person
lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not
met answers, word for word, `team.unavailable.one_request` when the model's capacity no longer met answers, word for word, `team.unavailable.one_request` when no model could serve as a subagent
allows a team (its metadata changed after the prompt was made), else `team.unavailable.one_agent` any more (the model's metadata changed after the prompt was made), else `team.unavailable.one_agent`
— which is also the tools' own `unavailable` text. — which is also the tools' own `unavailable` text.
The meaning of the setting: The meaning of the setting:
- **`max = 1`**: one agent, no team, and no helper of any kind: `task` answers `one_agent` too - **`max = 1`**: one agent, no team, and no subagent of any kind: `task` answers `one_agent` too
(while the lead has no member; with members left from a larger setting, `full`). (while the lead has no member; with members left from a larger setting, `full`).
- **`min ≥ 2`**: the lead works through a team from the start — it is a manager before its first - **`min ≥ 2`**: the lead works through a team from the start — it is a manager before its first
member (§6), and the note asks for at least `min − 1` members. member (§6), and the note asks for at least `min − 1` members.
@@ -72,14 +92,14 @@ The meaning of the setting:
## 3. Size, and what counts ## 3. Size, and what counts
**The size** of a team is: the lead, every member (whatever its state — a finished member can be **The size** of a team is: the lead, every member (whatever its state — a finished member can be
woken by a message, so it keeps its place), and every `task` helper running in the team, started by woken by a message, so it keeps its place), and every `task` subagent running in the team, started by
any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run). any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run).
A stopped member does not count: stopping is how room is made. A stopped member does not count: stopping is how room is made.
- **`team_add` is refused past `max`** — when the size would become more than `max`, with - **`team_add` is refused past `max`** — when the size would become more than `max`, with
`team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when `team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when
`max` is 1 and the lead has no member. A `job_send` that would start a finished background `max` is 1 and the lead has no member. A `job_send` that would start a finished background
`task` helper again is refused the same way. `task` subagent again is refused the same way.
- **Lowering `max` stops nobody.** Members already there stay; nothing new is added until the size - **Lowering `max` stops nobody.** Members already there stay; nothing new is added until the size
is under the new `max`. is under the new `max`.
- The size is across **the whole tree**: a member that leads adds within the same `max` as the lead. - The size is across **the whole tree**: a member that leads adds within the same `max` as the lead.
@@ -90,8 +110,9 @@ A stopped member does not count: stopping is how room is made.
machinery (a hidden chat on LLeMbas with its `chat_jobs` row, a subagent job in the CLI), running machinery (a hidden chat on LLeMbas with its `chat_jobs` row, a subagent job in the CLI), running
unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its
manager's (`permission/modes.json`). manager's (`permission/modes.json`).
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only); `model` is - **Its agent** is `general` (the default: it does the work) or `explore` (read-only). **Its model**
as `task`'s — a model the person may use, the default being the caller's. is the session's (the lead's) unless `model` gives one the person named in this session — as
`task`'s, refused with `subagents.texts` otherwise (`subagents.md` §2, §3).
- **Its tools** are those its agent gives a subagent, plus `team_send` and `team_read`; one added - **Its tools** are those its agent gives a subagent, plus `team_send` and `team_read`; one added
with `lead: true` also has `team_add` and `team_stop`. They are fixed when it is made (§10 rule 4: with `lead: true` also has `team_add` and `team_stop`. They are fixed when it is made (§10 rule 4:
a subagent's narrowing). `report` (`offered: subagent`) sends its manager a message before its a subagent's narrowing). `report` (`offered: subagent`) sends its manager a message before its
@@ -108,7 +129,8 @@ A stopped member does not count: stopping is how room is made.
`unavailable` (§2); `all_name` (the name `all`); `bad_name` (not `team.names.pattern`); `taken` `unavailable` (§2); `all_name` (the name `all`); `bad_name` (not `team.names.pattern`); `taken`
(the name of an agent on the team, `lead` included); `no_goal`; `full`. Then the member is added: (the name of an agent on the team, `lead` included); `no_goal`; `full`. Then the member is added:
`added`, or `added_waiting` when it has to wait for a slot (§8). An unknown `agent` or `model` `added`, or `added_waiting` when it has to wait for a slot (§8). An unknown `agent` or `model`
answers as `task` does. answers as `task` does; then `model`'s refusals, `not_named`, `same_group`, `one_request`
(`subagents.md`), in that order — before anything is added.
- **Members are not jobs to the model.** `job_list` does not show them and the `job_*` tools do not - **Members are not jobs to the model.** `job_list` does not show them and the `job_*` tools do not
take them; the team tools do. (An implementation may run them on its job machinery.) take them; the team tools do. (An implementation may run them on its job machinery.)
- **States**: `working` (a turn is running), `waiting` (it has work, and waits for a slot, §8), - **States**: `working` (a turn is running), `waiting` (it has work, and waits for a slot, §8),
@@ -171,7 +193,7 @@ class.
**The tool list does not change** when an agent becomes a manager (`context.md` §10, rule 4): the **The tool list does not change** when an agent becomes a manager (`context.md` §10, rule 4): the
same tools are listed and the refused ones answer the refusal. An agent is a manager while it has same tools are listed and the refused ones answer the refusal. An agent is a manager while it has
a member — a finished one included; one whose members were all stopped is not, unless it is the a member — a finished one included; one whose members were all stopped is not, unless it is the
lead and `min ≥ 2` with the team offered. `task` stays: a one-off helper is delegation, and counts lead and `min ≥ 2` with the team offered. `task` stays: a one-off subagent is delegation, and counts
toward the size (§3). toward the size (§3).
## 7. Delivery, and waking ## 7. Delivery, and waking
@@ -180,7 +202,7 @@ A message or a report reaches its recipient **as a marked message** — the jobs
(`loop.json` `jobs`): while the recipient's turn runs, as a user message at its next step boundary (`loop.json` `jobs`): while the recipient's turn runs, as a user message at its next step boundary
(which does not start a turn: `context.md` §8); otherwise it **wakes** the recipient — a finished (which does not start a turn: `context.md` §8); otherwise it **wakes** the recipient — a finished
member, or the idle lead — as a new turn of its own, marked `machine`, with its context intact, as member, or the idle lead — as a new turn of its own, marked `machine`, with its context intact, as
`job_send` restarts a finished helper. A woken agent needs a slot (§8). Several waiting at one step `job_send` restarts a finished subagent. A woken agent needs a slot (§8). Several waiting at one step
boundary go in one user message, in room order, separated by a blank line. boundary go in one user message, in room order, separated by a blank line.
Every one begins with its mark, on a line of its own, then the text: Every one begins with its mark, on a line of its own, then the text:
@@ -199,14 +221,16 @@ A session that is not open keeps them until it is resumed, as a job's note.
## 8. Capacity: a member waits for a slot ## 8. Capacity: a member waits for a slot
A model serves `parallel` requests at once (`models/schema.json`). **An agent holds a slot of its A model serves `parallel` requests at once (`models/schema.json`). **An agent holds a slot of its
model while its turn runs** — the lead, a member, a `task` helper. An agent with work (added, woken, model while its turn runs** — the lead, a member, a `task` subagent. An agent with work (added, woken,
given a message while finished, the lead given the person's message) starts when its model has a given a message while finished, the lead given the person's message) starts when its model has a
free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`. free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`.
`team_add` is never refused for capacity (only for size): its answer says the member waits. `team_add` is never refused for a full model (only for size, and for the model rules of
`subagents.md` §3: another model on the lead's one-model server, or a one-request session model):
its answer says the member waits.
Waiting agents start **in order**: the lead first (the person waits on it), then the others in the Waiting agents start **in order**: the lead first (the person waits on it), then the others in the
order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own
rule (`loop.json` `capacity`: refused where its model is full), and its helper holds a slot. rule (`loop.json` `capacity`: refused where its model is full), and its subagent holds a slot.
## 9. Stopping ## 9. Stopping
@@ -229,6 +253,10 @@ agent not under it (`not_yours`). The answer, `stopped`, names the stopped in tr
## 11. Over the link ## 11. Over the link
*3.4.0: the tree and the room are carried — `acp.md` capability `tree`; what follows is how a device
without it is still shown.*
An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over
ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events, ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events,
`description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown `description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown
+2 -2
View File
@@ -12,7 +12,7 @@
"subagent_run" "subagent_run"
] ]
}, },
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- Agents: {{agents}}", "description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- It runs on your model. Another model only when the person named it in this conversation; nothing else chooses one.\n- Agents: {{agents}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -38,7 +38,7 @@
"type": "boolean" "type": "boolean"
}, },
"model": { "model": {
"description": "Run it on another configured model (connection/model, from the list in your instructions) — a second opinion, or a model better at this kind of work. Default: the agent's own model, else yours.", "description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: your model.",
"type": "string" "type": "string"
} }
}, },
+32 -5
View File
@@ -14,7 +14,7 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}", "description": "Add a subagent and start it at once, in the background, on its part of the work. Only you, the main agent, add subagents. You lead: subagents do the work while you plan, coordinate and review — once you have one, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`). `role` is its position, one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first subagent needs it, and every subagent is told it.\n- `lead: true` makes it a sub-manager: it directs the subagents you place under it (`parent`), reviews their reports and reports to you; it does not add or stop subagents itself.\n- `mode` is its permission mode: its parent's by default, never looser. With a stricter mode you may give `request` options — kinds of action (read, write, execute) it may ask you for with team_request; you decide with team_decide.\n- The person sets how many agents the team may have, you included; past that this is refused. When the model is already serving as many requests as it can at once, the subagent waits for a slot and starts when one frees.\n- It reports with team_finish (or its final reply). Meanwhile team_send messages it, team_read shows the tree and the room, team_assign gives it new work, team_replace replaces it, team_stop stops it.\n- Agents: {{agents}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -26,7 +26,7 @@
"role": { "role": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "One line: what it owns" "description": "Its position, one line: what it owns"
}, },
"task": { "task": {
"type": "string", "type": "string",
@@ -39,7 +39,7 @@
}, },
"lead": { "lead": {
"type": "boolean", "type": "boolean",
"description": "May add members of its own and lead an area (default: false)" "description": "A sub-manager: directs the subagents placed under it (default: false)"
}, },
"agent": { "agent": {
"type": "string", "type": "string",
@@ -47,7 +47,33 @@
}, },
"model": { "model": {
"type": "string", "type": "string",
"description": "Run it on another model you may use (from the list in your instructions). Default: yours." "description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: the session's model."
},
"parent": {
"type": "string",
"description": "The sub-manager to place it under (default: you, lead)"
},
"mode": {
"type": "string",
"enum": [
"auto",
"edit",
"manual",
"plan"
],
"description": "Its permission mode: its parent's or stricter (default: its parent's)"
},
"request": {
"type": "array",
"items": {
"type": "string",
"enum": [
"read",
"write",
"execute"
]
},
"description": "With a stricter mode only: kinds of action it may ask you for (team_request)"
} }
}, },
"required": [ "required": [
@@ -58,5 +84,6 @@
}, },
"variables": { "variables": {
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)" "agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
} },
"offered": "main"
} }
+51
View File
@@ -0,0 +1,51 @@
{
"name": "team_assign",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"offered": "main",
"description": "Give a subagent that has not ended a new task: it is told at its next step, or woken. Optionally a new position (`role`), another sub-manager to work under (`parent`, or lead), or a stricter `mode`.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"task": {
"type": "string",
"minLength": 1
},
"role": {
"type": "string"
},
"parent": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"auto",
"edit",
"manual",
"plan"
]
}
},
"required": [
"name",
"task"
]
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "team_decide",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"offered": "main",
"description": "Decide a subagent's request (team_request): allow it or refuse it, with a note it is told. You may allow only what your own mode would allow you; anything beyond that goes to the person when the subagent tries.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"allow": {
"type": "boolean"
},
"note": {
"type": "string"
}
},
"required": [
"id",
"allow"
]
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "team_findings",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Findings kept beyond your context: `write` adds one (a fact, a result, where something is) to yours; `read` shows yours, or with `of` those of a subagent under you (the main agent: anyone's), ended ones included. Write as you go: when your context fills you have one turn left, and your findings are what the next agent starts from.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"write",
"read"
]
},
"text": {
"type": "string",
"description": "write: the finding"
},
"of": {
"type": "string",
"description": "read: a name or an id (default: yours)"
}
},
"required": [
"action"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "team_finish",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"offered": "subagent",
"description": "Your task is done: send your report to your parent and end. Make the report complete on its own — the result, then the evidence (file:line), then anything you could not settle. Save anything worth keeping beyond the report with team_findings first. Nothing runs after this call.",
"parameters": {
"type": "object",
"properties": {
"report": {
"type": "string",
"minLength": 1
}
},
"required": [
"report"
]
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "The team and its room: every agent (name, role, who leads it, state, model, last activity), and the room's messages after `since` that are yours to see — those you sent or received and those of the members under you; the lead sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.", "description": "The tree and its room: every agent (name, id, position, parent, state, model, mode, context use, last activity), open slots, subagents that have ended, and the room's messages after `since` that are yours to see — those you sent or received and those of the subagents under you; the main agent sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
+34
View File
@@ -0,0 +1,34 @@
{
"name": "team_replace",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "task",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"offered": "main",
"description": "Replace a subagent — one that ran out of context, or one that is stuck: it ends (stopped, if it has not already), and a new one with the same name, position, parent, mode and model takes its place, briefed with `task` (default: the old task) and the old one's findings. The subagents under it move under the new one.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"task": {
"type": "string"
}
},
"required": [
"name"
]
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "team_request",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"offered": "subagent",
"description": "Ask the main agent to allow a call your mode does not, of a kind your request options name. Say the tool, the permission pattern you need (a command such as `git push *`, or a path) and why. Its decision comes to you as a message; once allowed, make the call again.",
"parameters": {
"type": "object",
"properties": {
"tool": {
"type": "string",
"minLength": 1
},
"pattern": {
"type": "string",
"minLength": 1
},
"why": {
"type": "string",
"minLength": 1
}
},
"required": [
"tool",
"pattern",
"why"
]
}
}
+2 -2
View File
@@ -14,7 +14,7 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "Send a message through your team's room: to one or more agents by name, or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The team's lead is `lead`; your manager is reached by its name.", "description": "Send a message through the team's room: to one or more agents by name, to `parent` (the agent you report to), or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The main agent is `lead`.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -24,7 +24,7 @@
"type": "string" "type": "string"
}, },
"minItems": 1, "minItems": 1,
"description": "Names, or [\"all\"]" "description": "Names, [\"parent\"], or [\"all\"]"
}, },
"message": { "message": {
"type": "string", "type": "string",
+4 -3
View File
@@ -14,18 +14,19 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "Stop a member of your team, and the members under it, where they are: their work stops and they leave the team, which makes room for others. Only members under you, directly or through your members; `all` stops every one of them. Nothing they had not reported comes back — ask for a report with team_send first when you need it.", "description": "Stop a subagent, and the subagents under it, where they are: their work stops and they end, which makes room for others. Only you, the main agent, stop subagents; `all` stops every one. Nothing they had not reported comes back — ask for a report with team_send first, or read their findings with team_findings.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "name": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "A member under you, or all" "description": "A subagent's name, or all"
} }
}, },
"required": [ "required": [
"name" "name"
] ]
} },
"offered": "main"
} }
+22
View File
@@ -0,0 +1,22 @@
{
"name": "team_whoami",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Who you are on the team: your id, name, position, parent, task, permission mode and request options, model, limits, and how full your context is.",
"parameters": {
"type": "object",
"properties": {}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lembas-cli", "name": "lembas-cli",
"version": "2.0.0-beta.4", "version": "2.0.0-beta.6",
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.", "description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
+3 -3
View File
@@ -894,17 +894,17 @@
] ]
}, },
"agents": { "agents": {
"description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.", "description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session.",
"type": "object", "type": "object",
"properties": { "properties": {
"min": { "min": {
"description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.", "description": "Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
}, },
"max": { "max": {
"description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.", "description": "Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
+2 -2
View File
@@ -210,11 +210,11 @@
"maxLength": 300 "maxLength": 300
}, },
"single_session": { "single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.", "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1.",
"type": "boolean" "type": "boolean"
}, },
"parallel": { "parallel": {
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.", "description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2.",
"type": "integer", "type": "integer",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 9007199254740991 "maximum": 9007199254740991
+49 -2
View File
@@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
import type { Message } from "../provider/types.ts" import type { Message } from "../provider/types.ts"
import type { Hub } from "./hub.ts" import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts" import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
import { isNote } from "../session/prefix.ts" import { isNote } from "../session/prefix.ts"
export const ACP_VERSION = 1 export const ACP_VERSION = 1
@@ -273,6 +273,12 @@ export const CAPABILITIES = [
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client // Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
// said it can show files; the device sends the file with `_lembas/show`. // said it can show files; the device sends the file with `_lembas/show`.
"show", "show",
// Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent
// tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's
// Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's).
"progress",
"tree",
"agents",
] as const ] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" } export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -306,6 +312,8 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
...(o.patient ? { patient: true } : {}), ...(o.patient ? { patient: true } : {}),
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle. // Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}), ...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
}, },
}, },
} }
@@ -468,6 +476,10 @@ export class AcpAgent {
peer.handle("_lembas/job/stop", (p) => this.job("stop", p)) peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
peer.handle("_lembas/job/send", (p) => this.job("send", p)) peer.handle("_lembas/job/send", (p) => this.job("send", p))
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p)) peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
peer.handle("_lembas/team", (p) => this.tree("team", p))
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p)) peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p)) peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? ""))) peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
@@ -675,6 +687,8 @@ export class AcpAgent {
const effort = effortOf(p?.effort) const effort = effortOf(p?.effort)
// A level the model does not offer is read as its start level; the answer says the one used. // A level the model does not offer is read as its start level; the answer says the one used.
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort) if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
// Spec 3.4.0: the chat's Subagents setting, changed between turns.
if (p?.agents) this.takeAgents(s, { agents: p.agents })
return { model: s.app.engine.model.ref, effort: s.app.engine.effort } return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
} }
@@ -724,6 +738,19 @@ export class AcpAgent {
private takeContext(s: Session, p: any) { private takeContext(s: Session, p: any) {
const ctx = contextOf(p?._meta) const ctx = contextOf(p?._meta)
if (ctx) s.app.setContext(ctx, this.instanceConnection) if (ctx) s.app.setContext(ctx, this.instanceConnection)
this.takeAgents(s, p?._meta?.lembas)
}
/** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the
* instance — absent (an older one, an editor on serve --stdio): the device's own, as before.
* `said`: the person's earlier words in the chat. */
private takeAgents(s: Session, m: any) {
if (!this.lembas) return
const team = s.app.team.team
const a = m?.agents
if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max)
if (m?.subagents === false) team.subagents = false
if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string")
} }
/** The session's plan, as a `plan` update — after session/new and after opening from the /** The session's plan, as a `plan` update — after session/new and after opening from the
@@ -1067,6 +1094,8 @@ export class AcpAgent {
...(s.turns.last ? { lastTurnId: s.turns.last } : {}), ...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
plan: s.app.engine.plan ?? null, plan: s.app.engine.plan ?? null,
jobs: jobList(s.app), jobs: jobList(s.app),
progress: s.app.engine.progress.current,
tree: treeOf(s.app),
} }
const row = (this.store ??= new Store()).session(id) const row = (this.store ??= new Store()).session(id)
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored. // Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
@@ -1091,6 +1120,24 @@ export class AcpAgent {
return { jobs: jobList(this.get(p).app) } return { jobs: jobList(this.get(p).app) }
} }
/** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the
* session's subagent tree — the terminal's through the hub, as the jobs go. */
private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) {
const t = this.terminalFor(p)
if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000)
const app = this.get(p).app
switch (op) {
case "team":
return teamView(app, p?.since)
case "team_transcript":
return teamTranscript(app, p?.id)
case "team_findings":
return teamFindings(app, p?.id)
case "team_stop":
return teamStop(app, p?.id)
}
}
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */ /** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
private async job(op: "output" | "stop" | "send" | "transcript", p: any) { private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
const t = this.terminalFor(p) const t = this.terminalFor(p)
@@ -1291,7 +1338,7 @@ export class AcpAgent {
private forward(s: Session, e: Event) { private forward(s: Session, e: Event) {
// A model change says whether the model is this link's instance's: its ref is the // A model change says whether the model is this link's instance's: its ref is the
// provider's now (`deepseek/x`), so the ref alone no longer tells. // provider's now (`deepseek/x`), so the ref alone no longer tells.
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event }) if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
switch (e.type) { switch (e.type) {
case "text": case "text":
+46
View File
@@ -5,6 +5,7 @@
import type { App } from "../app.ts" import type { App } from "../app.ts"
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts" import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
import type { Store } from "../session/store.ts" import type { Store } from "../session/store.ts"
import type { TreeInfo } from "../team/team.ts"
import { historyTurns } from "./agent.ts" import { historyTurns } from "./agent.ts"
import { INVALID_PARAMS, RpcError } from "./rpc.ts" import { INVALID_PARAMS, RpcError } from "./rpc.ts"
@@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] {
} }
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")] return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
} }
// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ────────────────────
/** The tree (subagents.md §12), or null for a session with no subagents. */
export function treeOf(app: App): TreeInfo | null {
const t = app.team.team
if (!t.offered && !t.hasMembers() && !t.ended.length) return null
return t.info()
}
/** `_lembas/team`: the tree and the whole room above `since`. */
export function teamView(app: App, since: unknown) {
const n = typeof since === "number" && since >= 0 ? since : 0
const t = app.team.team
return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) }
}
function subagentOf(app: App, id: unknown) {
const key = String(id ?? "")
const a = app.team.team.find(key)
if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`)
return a
}
/** `_lembas/team/transcript`: a subagent's session, read-only. */
export function teamTranscript(app: App, id: unknown) {
const a = subagentOf(app, id)
const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined
const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : [])
return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) }
}
/** `_lembas/team/findings`. */
export function teamFindings(app: App, id: unknown) {
const a = subagentOf(app, id)
return { id: a.id, findings: a.findings.map((f) => ({ ...f })) }
}
/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */
export function teamStop(app: App, id: unknown) {
const a = subagentOf(app, id)
if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`)
const gone = app.team.personStop(a.name)
return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) }
}
+11 -2
View File
@@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts" import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
import { readLevel } from "../provider/effort.ts" import { readLevel } from "../provider/effort.ts"
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts" import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
import type { SessionContext } from "../app.ts" import type { SessionContext } from "../app.ts"
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts" import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
import { dropAttachments, preparePrompt } from "./prompt.ts" import { dropAttachments, preparePrompt } from "./prompt.ts"
@@ -454,7 +454,7 @@ export class Share {
return return
} }
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) { if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e }) this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e })
if (e.type === "task" && e.state === "end") this.sendCommands() if (e.type === "task" && e.state === "end") this.sendCommands()
} }
} }
@@ -520,6 +520,15 @@ export class Share {
return jobSend(this.app, p?.id, p?.text) return jobSend(this.app, p?.id, p?.text)
case "transcript": case "transcript":
return jobTranscript(this.app, p?.id) return jobTranscript(this.app, p?.id)
// Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go.
case "team":
return teamView(this.app, p?.since)
case "team_transcript":
return teamTranscript(this.app, p?.id)
case "team_findings":
return teamFindings(this.app, p?.id)
case "team_stop":
return teamStop(this.app, p?.id)
default: default:
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`) throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
} }
+84 -23
View File
@@ -16,6 +16,7 @@ import type { Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts" import { hardlineRules } from "./permission/hardline.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts" import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { subagentModel, type ModelInfo } from "./team/subagents.ts"
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts" import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
import type { Changing } from "./session/prefix.ts" import type { Changing } from "./session/prefix.ts"
import { describeRepo, repoState } from "./git/repo.ts" import { describeRepo, repoState } from "./git/repo.ts"
@@ -45,7 +46,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts" import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts" import { embedderFor } from "./library/embed.ts"
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts" import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts" import { memberTools as memberToolsOf, readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
import { whoDecides } from "./team/delegation.ts"
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts" import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
export interface AppOptions { export interface AppOptions {
@@ -202,8 +204,15 @@ function openInBrowser(url: string): boolean {
} }
} }
/** A model as the subagent rules see it (subagents.md): its names and what its server can do at once. */
function modelInfo(m: ResolvedModel): ModelInfo {
return { ref: m.ref, id: m.id, name: m.spec.name, capacity: { parallel: m.spec.parallel, single_session: m.spec.single_session, group: m.spec.group } }
}
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */ /** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name)) export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
/** Spec 3.4.0: tools the spec offers to the main agent only (`offered: main`): never a subagent's. */
export const MAIN_ONLY_TOOLS = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "main").map((t) => t.name))
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 } const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
export function stricterMode(a: Mode | undefined, b: Mode): Mode { export function stricterMode(a: Mode | undefined, b: Mode): Mode {
@@ -364,7 +373,7 @@ export function createApp(o: AppOptions): App {
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src } return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
} }
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => { const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }) => {
const src = ch.source as Source const src = ch.source as Source
return assembleSystem({ return assembleSystem({
modelRef: current.ref, modelRef: current.ref,
@@ -473,13 +482,22 @@ export function createApp(o: AppOptions): App {
// its server can take it beside the session's and beside every subagent already running // its server can take it beside the session's and beside every subagent already running
// (src/session/capacity.ts), before anything is made. // (src/session/capacity.ts), before anything is made.
const member = opts.member const member = opts.member
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model // Spec 3.3.0 (subagents.md): the session's model, unless the person named another; an agent
// file's own `model` is not used. A member's model was judged by team_add.
let m: ResolvedModel
if (member) m = opts.model ? resolveModel(loaded, opts.model) : engine.model
else {
const wanted = opts.model ? resolveModel(loaded, opts.model) : undefined
const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid())
if ("refused" in r) throw new ToolError(r.refused)
m = wanted && wanted.ref === r.model ? wanted : engine.model
}
// A team's member is not refused for capacity: the team gave it its slot (team.md §8). // A team's member is not refused for capacity: the team gave it its slot (team.md §8).
if (!member) { if (!member) {
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team) const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean) const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) if (refused) throw new ToolError(`${refused} Wait for one to finish, or do this part yourself.`)
} }
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation). // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined let wt: Worktree | undefined
@@ -508,13 +526,17 @@ export function createApp(o: AppOptions): App {
} }
: perm : perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply. // `report` is every subagent's: its way of speaking to the main agent before its final reply.
// A team member also has team_send and team_read, and one that may lead team_add and team_stop // A subagent of the tree also has its team tools (spec 3.4.0 §8: whoami, read, send, findings,
// (team.md §4); a task helper has none of them. // finish, and request with request options) — never the main agent's (`offered: main`); a task
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : [] // subagent has none of them.
const memberTools = member ? memberToolsOf(member) : []
const isTeamTool = (n: string) => (TEAM_TOOLS as readonly string[]).includes(n)
const childTools = [ const childTools = [
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))), ...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && !MAIN_ONLY_TOOLS.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name))),
] ]
// Its mode: the one the main agent gave it, made stricter by its agent's own (explore reads).
if (member) member.mode = stricterMode(spec.mode, member.mode)
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60)) const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
const ownerSession = jobsSessionId() const ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId) const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
@@ -524,7 +546,7 @@ export function createApp(o: AppOptions): App {
let job: Job let job: Job
// The asker is the session's; a child's ask says which subagent it comes from. // The asker is the session's; a child's ask says which subagent it comes from.
const askerFor = (): Asker => ({ const askerFor = (): Asker => ({
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }), ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }),
get question() { get question() {
return o.asker.question return o.asker.question
}, },
@@ -540,9 +562,9 @@ export function createApp(o: AppOptions): App {
tools: childTools, tools: childTools,
store, store,
sessionId: childRow?.id, sessionId: childRow?.id,
// An agent's own mode can make it stricter than its parent, never looser. // An agent's own mode can make it stricter than its parent, never looser. A subagent of the
// A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode. // tree runs in the mode the main agent gave it (spec 3.4.0 §7), set above.
mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode), mode: member ? member.mode : stricterMode(spec.mode, engine.mode),
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m), effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default, maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
compaction, compaction,
@@ -559,6 +581,13 @@ export function createApp(o: AppOptions): App {
fix: () => teamRt.team.offered, fix: () => teamRt.team.offered,
offered: () => teamRt.team.offered, offered: () => teamRt.team.offered,
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args), refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
// Spec 3.4.0 §7: itself, the main agent through a request, or the person.
permit: (request, ctx) => {
const a = teamRt.team.get(member.name)
return whoDecides(request, { ctx, mode: a?.mode ?? member.mode, main: engine.mode, request: a?.request ?? [], grants: a?.grants ?? [], unattended: o.unattended })
},
contextFull: (used: number, window: number) => (window ? teamRt.contextFull(member.name) : "go"),
ended: () => teamRt.hasEnded(member.name),
}, },
} }
: {}), : {}),
@@ -581,7 +610,7 @@ export function createApp(o: AppOptions): App {
}, },
at, at,
plan, plan,
member ? { name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined, member ? { id: member.id, name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
), ),
}) })
// Its own background commands wake it at its next step while it runs; once it has ended they // Its own background commands wake it at its next step while it runs; once it has ended they
@@ -597,14 +626,14 @@ export function createApp(o: AppOptions): App {
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) }) const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) } return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
} }
// A task helper started inside a team counts toward its size and holds a slot while it runs // A task subagent started inside a team counts toward its size and holds a slot while it runs
// (team.md §3, §8). // (team.md §3, §8).
const counted = (p: Promise<SubagentResult>) => { const counted = (p: Promise<SubagentResult>) => {
if (member || !teamRt.team.offered) return p if (member || !teamRt.team.offered) return p
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref) const id = teamRt.team.taskStart(TEAM.names.lead, m.ref)
return p.finally(() => teamRt.team.helperDone(id)) return p.finally(() => teamRt.team.taskDone(id))
} }
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))) const run = (text: string, machine = false, steps?: number) => counted(child.prompt(text, [], text, { ...(machine ? { machine: true as const } : {}), ...(steps ? { maxSteps: steps } : {}) }).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
const handle: SubagentHandle = { const handle: SubagentHandle = {
bus: childBus, bus: childBus,
engine: child, engine: child,
@@ -616,15 +645,26 @@ export function createApp(o: AppOptions): App {
if (member) { if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it. // A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name job.team = member.name
const memberRun = (text: string, machine: boolean) => { member.jobId = job.id
// Its progress and context use, for the tree (spec 3.4.0 §12).
childBus.on((e) => {
if (e.type === "phase") {
teamRt.team.setProgress(member.name, e.progress)
teamRt.notify()
} else if (e.type === "usage" && !e.child && e.context) {
teamRt.team.setContext(member.name, e.used ?? e.usage.input + e.usage.output, e.context)
teamRt.notify()
}
})
const memberRun = (text: string, machine: boolean, steps?: number) => {
if (job.state !== "running") registry.restartSubagent(job) if (job.state !== "running") registry.restartSubagent(job)
void run(text, machine).then((r) => { void run(text, machine, steps).then((r) => {
const leftover = [...child.takeNotes(), ...child.takeInbox()] const leftover = [...child.takeNotes(), ...child.takeInbox()]
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover) if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
}) })
} }
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel() } const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel(), final: (text) => memberRun(text, true, TEAM.context.final_steps) }
teamRt.attach(member.name, mh) teamRt.attach(member.name, mh)
memberRun(prompt, false) memberRun(prompt, false)
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh } return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
@@ -683,6 +723,8 @@ export function createApp(o: AppOptions): App {
return {} return {}
} }
} }
// Every model the person may use: the server's, as the login read them (subagents.md §4).
const usableModels = () => loaded.refs.map((r) => r.ref)
// The size as set while the process runs (any scope): a new or resumed session keeps it. // The size as set while the process runs (any scope): a new or resumed session keeps it.
const teamSize: { min?: number; max?: number } = {} const teamSize: { min?: number; max?: number } = {}
const newTeam = () => const newTeam = () =>
@@ -693,8 +735,11 @@ export function createApp(o: AppOptions): App {
subagents: loaded.config.subagents !== false, subagents: loaded.config.subagents !== false,
model: engine.model.ref, model: engine.model.ref,
capacity: capacityOf, capacity: capacityOf,
models: usableModels,
leadState: "idle", leadState: "idle",
readingBash: (command) => readingBashByDefault(command, project.root), readingBash: (command) => readingBashByDefault(command, project.root),
mode: engine.mode,
steps: { main: loaded.config.limits?.steps ?? LOOP.steps.default, sub: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default },
}) })
const teamRt: TeamRuntime = new TeamRuntime({ const teamRt: TeamRuntime = new TeamRuntime({
team: newTeam(), team: newTeam(),
@@ -707,11 +752,19 @@ export function createApp(o: AppOptions): App {
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, []) teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
}) })
}, },
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) }, lead: {
note: (text) => engine.note(text),
wake: (text) => wake(text, shownLine(text)),
waiting: (model) => engine.progress.set({ phase: "waiting_for_model", model: engine.model.spec.name ?? model, reason: "slots" }),
},
tools: () => tools,
resolve: (agent, model) => { resolve: (agent, model) => {
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` } if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
try { try {
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref } // Spec 3.3.0 (subagents.md): the session's model, unless the person named another.
const wanted = model ? resolveModel(loaded, model) : undefined
const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid())
return "refused" in r ? { error: r.refused } : { model: r.model }
} catch (e) { } catch (e) {
return { error: (e as Error).message } return { error: (e as Error).message }
} }
@@ -719,6 +772,14 @@ export function createApp(o: AppOptions): App {
}) })
// The lead's turn ended: it is idle, and its slot frees (team.md §8). // The lead's turn ended: it is idle, and its slot frees (team.md §8).
bus.on((e) => e.type === "done" && teamRt.leadDone()) bus.on((e) => e.type === "done" && teamRt.leadDone())
// Spec 3.4.0: the main agent's mode reaches down the tree; its progress and context are in it.
bus.on((e) => {
if (e.type === "mode") teamRt.mainMode(e.mode)
else if (e.type === "phase") {
teamRt.team.setProgress(TEAM.names.lead, e.progress)
if (teamRt.team.hasMembers()) teamRt.notify()
} else if (e.type === "usage" && !e.child && e.context) teamRt.team.setContext(TEAM.names.lead, e.used ?? e.usage.input + e.usage.output, e.context)
})
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt). // MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>() let mcpNames = new Set<string>()
+5 -2
View File
@@ -47,7 +47,7 @@ export type Event =
| { type: "steered"; texts: string[]; ids?: string[] } | { type: "steered"; texts: string[]; ids?: string[] }
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */ /** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
| { type: "task"; state: "start" | "end"; turnId?: string } | { type: "task"; state: "start" | "end"; turnId?: string }
| { type: "notice"; message: string; level?: "info" | "warn" } | { type: "notice"; message: string; level?: "info" | "warn"; /** Spec 3.4.0: a request that will be sent again. */ retry?: { reason: string; in: number; attempt?: number } }
/** Discard the reply streamed so far in this step: it is being retried. */ /** Discard the reply streamed so far in this step: it is being retried. */
| { type: "retract" } | { type: "retract" }
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */ /** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
@@ -65,6 +65,9 @@ export type Event =
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a /** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
* message in the room. Read the team itself for what. */ * message in the room. Read the team itself for what. */
| { type: "team" } | { type: "team" }
/** Spec 3.4.0 (harness/progress.md): what the session's agent is doing now, or null. A
* subagent's progress is in the tree (`team`). */
| { type: "phase"; progress: import("../session/progress.ts").Progress | null }
/** The permission mode changed from inside a turn (plan approved). */ /** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode } | { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings /** The session's model or effort changed, for whatever reason — /model, /effort, the settings
@@ -127,7 +130,7 @@ export interface Asker {
purpose?: string purpose?: string
/** Set when a subagent running as a background job asks, so the card can say which — it may /** Set when a subagent running as a background job asks, so the card can say which — it may
* ask while the session itself is idle. */ * ask while the session itself is idle. */
source?: { jobId: string; agent: string; description: string } source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
}): Promise<AskReply> }): Promise<AskReply>
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's /** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
* id, which keys the card on another screen. */ * id, which keys the card on another screen. */
+5 -5
View File
@@ -60,8 +60,8 @@ export const ModelSpec = z.strictObject({
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."), headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."), body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."), notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."), single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1."),
parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."), parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2."),
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."), group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
}) })
export type ModelSpec = z.infer<typeof ModelSpec> export type ModelSpec = z.infer<typeof ModelSpec>
@@ -298,12 +298,12 @@ export const Config = z.strictObject({
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."), effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
agents: z agents: z
.strictObject({ .strictObject({
min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."), min: z.number().int().min(1).optional().describe("Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start."),
max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."), max: z.number().int().min(1).optional().describe("Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents."),
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."), cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
}) })
.optional() .optional()
.describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."), .describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session."),
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."), subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
+8 -2
View File
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { parse, parseDocument } from "yaml" import { parse, parseDocument } from "yaml"
import { z } from "zod" import { z } from "zod"
import LOOP_JSON from "../../harness/loop.json"
import { paths } from "./paths.ts" import { paths } from "./paths.ts"
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts" import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
@@ -29,8 +30,13 @@ export interface SettingDef {
reload?: boolean reload?: boolean
/** What it is when nothing sets it. */ /** What it is when nothing sets it. */
fallback?: string fallback?: string
/** What a person reads beside the key in /settings. */
hint?: string
} }
// The Subagents setting's names and what its numbers count (harness loop.json subagents.setting).
const SUB = LOOP_JSON.subagents.setting
const ALL: Scope[] = ["session", "global", "project"] const ALL: Scope[] = ["session", "global", "project"]
const USER: Scope[] = ["session", "global"] const USER: Scope[] = ["session", "global"]
@@ -38,8 +44,8 @@ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL }, { key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" }, { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
// The team's size is the person's: the agent never sets how many agents it may have. // The team's size is the person's: the agent never sets how many agents it may have.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" }, { key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1", hint: `${SUB.label}: ${SUB.min} — ${SUB.short}` },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" }, { key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4", hint: `${SUB.label}: ${SUB.max} — this agent included; 4 is this agent and up to 3 subagents` },
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
+63 -7
View File
@@ -52,6 +52,13 @@ import t_team_add from "../harness/tools/team_add.json"
import t_team_read from "../harness/tools/team_read.json" import t_team_read from "../harness/tools/team_read.json"
import t_team_send from "../harness/tools/team_send.json" import t_team_send from "../harness/tools/team_send.json"
import t_team_stop from "../harness/tools/team_stop.json" import t_team_stop from "../harness/tools/team_stop.json"
import t_team_assign from "../harness/tools/team_assign.json"
import t_team_decide from "../harness/tools/team_decide.json"
import t_team_findings from "../harness/tools/team_findings.json"
import t_team_finish from "../harness/tools/team_finish.json"
import t_team_replace from "../harness/tools/team_replace.json"
import t_team_request from "../harness/tools/team_request.json"
import t_team_whoami from "../harness/tools/team_whoami.json"
import t_view_image from "../harness/tools/view_image.json" import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json" import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json" import t_web_search from "../harness/tools/web_search.json"
@@ -76,8 +83,9 @@ export interface ToolSpecDef {
purpose: string | false purpose: string | false
/** Names each project used before spec v1; a call under one is read as this tool. */ /** Names each project used before spec v1; a call under one is read as this tool. */
aliases: { cli: string[]; llembas: string[] } aliases: { cli: string[]; llembas: string[] }
/** `subagent`: offered only to a subagent (report), never to a session's own model. */ /** `subagent`: offered only to a subagent (report, team_finish, team_request), never to a
offered?: "subagent" * session's own model; `main` (spec 3.4.0): only to the main agent, never to a subagent. */
offered?: "subagent" | "main"
argument_aliases?: Record<string, string> argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file /** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's); * can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
@@ -118,16 +126,62 @@ export const LOOP: {
running_max: { default: number } running_max: { default: number }
subagent_steps: { default: number } subagent_steps: { default: number }
} }
/** Subagents (spec 3.3.0, harness/subagents.md): the model refusals, what names a model, and how
* the size setting is shown. */
subagents: {
texts: { not_named: string; same_group: string; one_request: string }
named: { before: string; after: string }
setting: { label: string; min: string; max: string; hint: string; short: string; own: string }
}
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */ /** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
team: { team: {
agents: { min: number; max: number; cap: number } agents: { min: number; max: number; cap: number }
names: { pattern: string; lead: string; reserved: string[] } names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
marks: { message: string; report: string } marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
unavailable: { one_agent: string; one_request: string } unavailable: { one_agent: string; one_request: string }
work: { write: string[]; execute: string[]; kept: string[] } work: { write: string[]; execute: string[]; kept: string[] }
read_max: number read_max: number
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string> texts: Record<
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string } | "added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
// Spec 3.4.0 (subagents.md §5–§13).
| "only_main" | "not_manager" | "wider" | "request_not_narrower" | "request_kind" | "requestable" | "not_delegated" | "not_requestable" | "requested" | "no_request" | "decided" | "allowed" | "refused" | "decision_allowed" | "decision_refused" | "no_parent" | "finished" | "has_ended" | "cycle" | "assigned" | "replaced" | "replace_brief" | "no_findings" | "noted" | "not_yours_findings" | "context_full" | "gone" | "request_body",
string
>
read: {
tree: string
agent: string
lead_role: string
states: Record<"working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped", string>
active: string
room: string
message: string
report: string
none: string
more: string
slots: string
ended: string
ended_agent: string
context: string
request: string
}
/** Spec 3.4.0. */
main_only: { tools: string[] }
context: { pause_at: number; final_steps: number }
request: { kinds: string[]; id_prefix: string }
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
findings: { header: string; item: string }
}
/** Progress (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
progress: {
phases: string[]
texts: Record<string, string>
reason: Record<string, string>
what: Record<string, string>
stopped_reason: Record<string, string>
line: string
no_elapsed: string[]
what_max: number
pace_per_second: number
} }
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's /** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table * header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
@@ -165,6 +219,8 @@ export const LOOP: {
team_lead: string team_lead: string
team_one: string team_one: string
team_stopped: string team_stopped: string
/** Spec 3.3.0 (subagents.md §4): the session's model cannot serve subagents itself. */
team_own: string
} }
/** How long a session waits for its configured MCP servers before its first request. */ /** How long a session waits for its configured MCP servers before its first request. */
mcp_wait_ms: number mcp_wait_ms: number
@@ -245,7 +301,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
} }
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries( export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_read, t_team_send, t_team_stop, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), ([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_assign, t_team_decide, t_team_findings, t_team_finish, t_team_read, t_team_replace, t_team_request, t_team_send, t_team_stop, t_team_whoami, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
) )
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and /** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
+1
View File
@@ -13,6 +13,7 @@ export interface AgentSpec {
name: string name: string
description: string description: string
instructions: string instructions: string
/** Read, and not used for a subagent (spec 3.3.0, subagents.md §2): shown so a file that sets it says so. */
model?: string model?: string
/** A fixed permission mode; absent = the parent's. */ /** A fixed permission mode; absent = the parent's. */
mode?: Mode mode?: Mode
+9 -5
View File
@@ -20,6 +20,7 @@ import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" } import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" } import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" } import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" }
import blockTeamSubmanager from "../../harness/prompts/blocks/team_submanager.md" with { type: "text" }
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" } import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" } import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" } import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
@@ -61,6 +62,7 @@ const BUILTIN: Record<string, string> = {
"blocks/context.md": blockContext, "blocks/context.md": blockContext,
"blocks/team_lead.md": blockTeamLead, "blocks/team_lead.md": blockTeamLead,
"blocks/team_member.md": blockTeamMember, "blocks/team_member.md": blockTeamMember,
"blocks/team_submanager.md": blockTeamSubmanager,
"agents/explore.md": agentExplore, "agents/explore.md": agentExplore,
"agents/general.md": agentGeneral, "agents/general.md": agentGeneral,
"modes/manual.md": modeManual, "modes/manual.md": modeManual,
@@ -236,9 +238,9 @@ export interface PromptContext {
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions. /** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */ * `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean } subagent?: { name: string; instructions: string; report?: boolean }
/** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md, /** Spec 3.2.0 (team.md §4), 3.4.0: a subagent of the tree — blocks/team_member.md in place of
* with the whole of blocks/team_lead.md for one that may lead. */ * blocks/subagent.md, with blocks/team_submanager.md for a sub-manager. */
member?: { name: string; manager: string; role: string; goal: string; lead: boolean } member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead): /** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
* blocks/team_lead.md is in its prompt. */ * blocks/team_lead.md is in its prompt. */
teamLead?: boolean teamLead?: boolean
@@ -345,7 +347,7 @@ export function assembleSystem(ctx: PromptContext): string {
].join("\n"), ].join("\n"),
) )
if (ctx.roster && !ctx.subagent) if (ctx.roster && !ctx.subagent)
sections.push(`Other models configured here — the task tool can run a subagent on one (its model argument); the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`) sections.push(`Other models configured here — a subagent runs on one (task's or team_add's model argument) only when the user names it; the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`) if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`) if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
@@ -391,12 +393,14 @@ export function assembleSystem(ctx: PromptContext): string {
sections.push( sections.push(
fillPrompt(promptText("blocks/team_member.md"), { fillPrompt(promptText("blocks/team_member.md"), {
name: ctx.member.name, name: ctx.member.name,
id: ctx.member.id,
manager: ctx.member.manager, manager: ctx.member.manager,
role: ctx.member.role, role: ctx.member.role,
goal: ctx.member.goal, goal: ctx.member.goal,
instructions: ctx.subagent.instructions, instructions: ctx.subagent.instructions,
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "", read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
lead: ctx.member.lead ? promptText("blocks/team_lead.md") : "", // Spec 3.4.0: a sub-manager directs; it no longer gets the whole lead block.
lead: ctx.member.lead ? promptText("blocks/team_submanager.md") : "",
}) })
.replace(/\n{3,}/g, "\n\n") .replace(/\n{3,}/g, "\n\n")
.trim(), .trim(),
+2 -1
View File
@@ -54,7 +54,8 @@ export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, hand
// Twice: the first at once, the second after a pause (a model still loading, a busy server). // Twice: the first at once, the second after a pause (a model still loading, a busy server).
if ((e.status ?? 0) >= 500 && retried5xx < 2) { if ((e.status ?? 0) >= 500 && retried5xx < 2) {
retried5xx++ retried5xx++
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` } const reason = e.message.split("\n")[0]!.slice(0, 160)
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${reason}`, retry: { reason, in: retried5xx === 2 ? 2 : 0, attempt: retried5xx } }
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000)) if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
continue continue
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export type StreamEvent =
| { type: "progress"; total: number; cache: number; processed: number; ms: number } | { type: "progress"; total: number; cache: number; processed: number; ms: number }
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> } | { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
/** Something worth telling the user that is not an error, e.g. a retry. */ /** Something worth telling the user that is not an error, e.g. a retry. */
| { type: "notice"; message: string } | { type: "notice"; message: string; retry?: { reason: string; in: number; attempt?: number } }
export interface ChatRequest { export interface ChatRequest {
system: string system: string
+15 -15
View File
@@ -20,21 +20,21 @@ export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
return m.spec.single_session ? 1 : Infinity return m.spec.single_session ? 1 : Infinity
} }
/** `busy`: this session's requests the helper's model already serves besides the main prompt's /** `busy`: this session's requests the sub's model already serves besides the main prompt's
* (subagents running on it). */ * (subagents running on it). */
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string { export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string {
if (helper.ref === main.ref) { if (sub.ref === main.ref) {
const n = parallelOf(helper) const n = parallelOf(sub)
if (n === 1) if (n === 1)
return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` return `${sub.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
if (1 + busy >= n) if (1 + busy >= n)
return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.` return `${sub.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
return "" return ""
} }
const shared = groupOf(main) const shared = groupOf(main)
if (shared !== undefined && shared === groupOf(helper)) { if (shared !== undefined && shared === groupOf(sub)) {
const where = `${main.ref}'s server (group ${main.spec.group})` const where = `${main.ref}'s server (group ${main.spec.group})`
return `${where} holds one model at a time, so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.` return `${where} holds one model at a time, so a subagent on ${sub.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
} }
return "" return ""
} }
@@ -42,17 +42,17 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy
/** The same rule between two subagents: a new one is not started where its model would unload, or /** The same rule between two subagents: a new one is not started where its model would unload, or
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent * take the only slot of, a subagent already running (the spec's "beside the model of a subagent
* already running"). */ * already running"). */
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string { export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
if (helper.ref === running.ref) { if (sub.ref === running.ref) {
const n = parallelOf(helper) const n = parallelOf(sub)
if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.` if (n === 1) return `${sub.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.`
if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.` if (busy >= n) return `${sub.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
return "" return ""
} }
const shared = groupOf(running) const shared = groupOf(running)
if (shared !== undefined && shared === groupOf(helper)) { if (shared !== undefined && shared === groupOf(sub)) {
const where = `${running.ref}'s server (group ${running.spec.group})` const where = `${running.ref}'s server (group ${running.spec.group})`
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${helper.ref} would unload it.` return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${sub.ref} would unload it.`
} }
return "" return ""
} }
+50 -7
View File
@@ -17,6 +17,9 @@ import { fill, promptText } from "../prompt/assemble.ts"
import { skillMessage, slug, type Skill } from "../skill/index.ts" import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts" import { CONTINUE_PROMPT } from "./engine.ts"
import { TEAM, Team } from "../team/team.ts" import { TEAM, Team } from "../team/team.ts"
import { SUBAGENTS } from "../team/subagents.ts"
import { progressLine } from "./progress.ts"
import { fillPrompt } from "../prompt/fill.ts"
export type CommandKind = "custom" | "skill" | "builtin" export type CommandKind = "custom" | "skill" | "builtin"
@@ -48,7 +51,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" }, { name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size <min> <max> | stop <name>" }, { name: "team", description: "subagents: the tree and the room; how many; stop one; their findings", hint: "size <min> <max> | stop <name> | findings <name>" },
] ]
/** The status bar's word for a team with members: its size of the most it may have, and how many /** The status bar's word for a team with members: its size of the most it may have, and how many
@@ -56,7 +59,35 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
export function teamLabel(team: Team): string { export function teamLabel(team: Team): string {
if (!team.hasMembers()) return "" if (!team.hasMembers()) return ""
const n = team.active() const n = team.active()
return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}` return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}`
}
/** Spec 3.4.0 (subagents.md §12): the tree as the person sees it — every agent with its position,
* state, model, mode, context use, progress line and messages; open slots; the ended ones. */
export function treeText(team: Team, now = Date.now()): string {
const info = team.info()
const R = TEAM.read
const depth = (a: { parent: string | null }): number => (a.parent ? 1 + depth(info.agents.find((x) => x.name === a.parent) ?? { parent: null }) : 0)
const lines = [`${SUBAGENTS.setting.label} (${info.size} of ${info.max} agents; ${SUBAGENTS.setting.min} ${info.min}, ${SUBAGENTS.setting.max} ${info.max}):`]
for (const a of info.agents) {
const bits = [
`${" ".repeat(depth(a))}- ${a.name}${a.id === a.name ? "" : ` (${a.id})`} — ${a.position}`,
R.states[a.state],
a.model,
a.request.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode,
a.context && a.context.window ? `${Math.floor((a.context.used * 100) / a.context.window)}% context` : "",
a.progress ? progressLine(a.progress, now) : "",
a.messages ? `${a.messages} message${a.messages === 1 ? "" : "s"}` : "",
a.findings ? `${a.findings} finding${a.findings === 1 ? "" : "s"}` : "",
].filter(Boolean)
lines.push(bits.join(" · "))
}
if (info.open_slots) lines.push(fillPrompt(R.slots, { n: String(info.open_slots) }))
if (info.ended.length) {
lines.push(R.ended)
for (const a of info.ended) lines.push(`- ${a.name} (${a.id}) — ${a.position} · ${R.states[a.state]}${a.findings ? ` · ${a.findings} finding${a.findings === 1 ? "" : "s"}` : ""}`)
}
return lines.join("\n")
} }
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the /** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
@@ -65,10 +96,11 @@ export function teamLabel(team: Team): string {
export function teamText(app: App, arg: string): string { export function teamText(app: App, arg: string): string {
const rt = app.team const rt = app.team
const t = rt.team const t = rt.team
const S = SUBAGENTS.setting
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean) const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
if (verb === "size") { if (verb === "size") {
const [min, max] = rest.map(Number) const [min, max] = rest.map(Number)
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})` if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} (Max at most ${t.cap}). ${S.hint}`
// Checked whole before anything is set: a refused pair changes nothing (team.md §2). // Checked whole before anything is set: a refused pair changes nothing (team.md §2).
const no = Team.check(min, max, t.cap) const no = Team.check(min, max, t.cap)
if (no) return no if (no) return no
@@ -79,7 +111,12 @@ export function teamText(app: App, arg: string): string {
} catch (e) { } catch (e) {
return (e as Error).message return (e as Error).message
} }
return `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.` return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.`
}
if (verb === "findings") {
const name = rest[0]
if (!name) return "/team findings <name or id>"
return t.readFindings("lead", name)
} }
if (verb === "stop") { if (verb === "stop") {
const name = rest[0] const name = rest[0]
@@ -88,9 +125,15 @@ export function teamText(app: App, arg: string): string {
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.` return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
} }
if (!t.offered) if (!t.offered)
return t.subagents ? `No team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)." return t.subagents
? `No subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.`
: "No subagents in this session: they are switched off (subagents: false)."
const since = Math.max(0, t.room.length - TEAM.read_max) const since = Math.max(0, t.room.length - TEAM.read_max)
return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size <min> <max>); /team stop <name> stops a member.` const own = t.couldServe().own ? "" : `\n${S.own}`
// The tree as the person sees it (spec 3.4.0 §12), then the room as team_read shows it.
const read = t.read("lead", since)
const room = read.slice(read.indexOf("\n\n") + 2)
return `${treeText(t)}\n\n${room}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a subagent; /team open <name> shows its session, read-only; /team findings <name>; /context <name>.${own}`
} }
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
@@ -100,7 +143,7 @@ export function agentsText(app: App, arg: string): string {
const describe = (a: (typeof list)[number]) => const describe = (a: (typeof list)[number]) =>
[ [
`${a.name} (${a.source}) — ${a.description}`, `${a.name} (${a.source}) — ${a.description}`,
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, ` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
].join("\n") ].join("\n")
const name = arg.trim().split(/\s+/)[0] const name = arg.trim().split(/\s+/)[0]
if (name) { if (name) {
+39 -13
View File
@@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images }) if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0) const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
const reported = engine.usage ? engine.contextUsed() : undefined const reported = engine.usage ? engine.contextUsed() : undefined
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest } // Where automatic compaction starts (EngineOptions compaction.autoAt): the reference's "N until
// auto-compact". None without a window, or when it is switched off (1 or more).
const autoAt = engine.o.compaction?.autoAt ?? 0.85
return { rows, estimated, reported, window: contextWindow(engine.model), autoAt: autoAt < 1 ? autoAt : undefined, model: engine.model.ref, last: engine.lastRequest }
} }
/** Plain text, for the transcript. */ /** One glyph per category in the segmented bar, in the order the categories are listed. */
const SEGMENTS = ["█", "▓", "▒", "░", "▞", "▚", "▙", "▟"]
/** Plain text, for the transcript: what is used of the window and how much, one segmented bar of
* every category, each category with its tokens and its share of the window, and how far it is to
* automatic compaction (/compact compacts now). */
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string { export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
const width = 24 const width = 40
const total = b.window ?? Math.max(b.estimated, b.reported ?? 0)
const bar = (n: number) => {
const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0
return "█".repeat(Math.min(width, cells)).padEnd(width, "·")
}
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`]
for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`)
const used = b.reported ?? b.estimated const used = b.reported ?? b.estimated
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`) const total = b.window ?? Math.max(b.estimated, used)
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)") const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const pct = (n: number) => (b.window ? `${Math.round((n / b.window) * 100)}%` : "")
const cats = b.rows.filter((r) => !r.indent)
// The categories' shares of the bar, scaled to what is in use (the server's count when it gave one).
const scale = b.estimated ? used / b.estimated : 1
let bar = ""
cats.forEach((r, i) => {
const cells = total && r.tokens > 0 ? Math.max(1, Math.round(((r.tokens * scale) / total) * width)) : 0
bar += SEGMENTS[i % SEGMENTS.length]!.repeat(cells)
})
bar = bar.slice(0, width).padEnd(width, "·")
const lines = [
`context — ${b.model}: ${fmt(used)}${b.window ? ` / ${fmt(b.window)} tokens · ${pct(used)} used` : " tokens, window unknown"}`,
` ${bar}`,
]
let i = 0
for (const r of b.rows) {
const mark = r.indent ? " ↳" : ` ${SEGMENTS[i++ % SEGMENTS.length]} `
lines.push(`${mark} ${r.label.padEnd(r.indent ? 30 : 31)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : pct(r.tokens * scale).padStart(4)}`.trimEnd())
}
if (b.window) lines.push(` · ${"free".padEnd(31)} ${fmt(Math.max(0, b.window - used)).padStart(7)} ${pct(Math.max(0, b.window - used)).padStart(4)}`)
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${pct(used).padStart(4)}`.trimEnd())
if (b.window && b.autoAt !== undefined) {
const until = Math.round(b.window * b.autoAt) - used
lines.push(until > 0 ? ` ${fmt(until)} until auto-compact (at ${Math.round(b.autoAt * 100)}%) — /compact to compact now` : ` past auto-compact (${Math.round(b.autoAt * 100)}%): the next step compacts — /compact to compact now`)
} else lines.push(b.window ? " automatic compaction is off — /compact to compact now" : " no automatic compaction without a window — /compact to compact now")
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since; the categories are estimates scaled to it)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with // What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
// little of it cached was a re-read. // little of it cached was a re-read.
const last = b.last?.usage const last = b.last?.usage
+68 -10
View File
@@ -8,7 +8,7 @@ import { isAbsolute } from "node:path"
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts" import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
import type { Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import type { Level } from "../provider/effort.ts" import type { Level } from "../provider/effort.ts"
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts" import { evaluate, shadowsDeny, type Context as PermContext, type PermissionRequest, type Rule } from "../permission/evaluate.ts"
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts" import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
import type { Plan } from "../plan/plan.ts" import type { Plan } from "../plan/plan.ts"
import { LOOP } from "../harness.ts" import { LOOP } from "../harness.ts"
@@ -22,6 +22,9 @@ import { estimateTokens, estimateValue } from "../provider/tokens.ts"
import { duration } from "../duration.ts" import { duration } from "../duration.ts"
import { memoryResults, presentIds, ResultContext } from "./results.ts" import { memoryResults, presentIds, ResultContext } from "./results.ts"
import { render } from "../plan/plan.ts" import { render } from "../plan/plan.ts"
import { personTexts } from "../team/subagents.ts"
import { ProgressTracker } from "./progress.ts"
import type { Verdict } from "../team/delegation.ts"
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts" import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
export interface EngineOptions { export interface EngineOptions {
@@ -77,6 +80,15 @@ export interface EngineOptions {
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */ /** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
forget?(): void forget?(): void
beforeTurn?(signal: AbortSignal): Promise<boolean> beforeTurn?(signal: AbortSignal): Promise<boolean>
/** Spec 3.4.0 (subagents.md §7), a subagent: who decides its call — itself, the main agent
* (through a request), or the person. Replaces the plain evaluation. */
permit?(request: PermissionRequest, ctx: Omit<PermContext, "mode">): Verdict
/** Spec 3.4.0 §9, a subagent: its context reached `pause_at` of its window before a step.
* "stop": its turn ends now (it is paused; its final turn follows); "go": carry on without
* compacting (it is in its final turn). Undefined: not a subagent of the tree. */
contextFull?(used: number, window: number): "stop" | "go" | undefined
/** Spec 3.4.0, a subagent: it ended by its own call (team_finish): the turn stops after this step. */
ended?(): boolean
} }
} }
@@ -86,6 +98,8 @@ export interface PromptMeta {
attachments?: AttachmentInfo[] attachments?: AttachmentInfo[]
/** The harness started this turn — a job's note — not the person. */ /** The harness started this turn — a job's note — not the person. */
machine?: true machine?: true
/** Spec 3.4.0: the steps this turn may take, when fewer than the engine's (a subagent's final turn). */
maxSteps?: number
} }
const MAX_PARALLEL = LOOP.parallel.max const MAX_PARALLEL = LOOP.parallel.max
@@ -186,7 +200,11 @@ export class Engine {
/** The prompt the team's offer was last decided for. */ /** The prompt the team's offer was last decided for. */
private teamFixed: Kept | undefined private teamFixed: Kept | undefined
/** Spec 3.4.0 (harness/progress.md): what this agent is doing, said as `phase` events. */
readonly progress: ProgressTracker
constructor(readonly o: EngineOptions) { constructor(readonly o: EngineOptions) {
this.progress = new ProgressTracker(o.bus)
this.mode = o.mode this.mode = o.mode
this._effort = o.effort this._effort = o.effort
this.model = o.model this.model = o.model
@@ -463,8 +481,19 @@ export class Engine {
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never /** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the * pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
* prompt cache away from that point); compaction is the one deliberate break. */ * prompt cache away from that point); compaction is the one deliberate break. */
private async manageContext(signal: AbortSignal) { private async manageContext(signal: AbortSignal): Promise<"stop" | void> {
const window = contextWindow(this.model) const window = contextWindow(this.model)
// Spec 3.4.0 §9: a subagent of the tree does not compact; at pause_at it is paused instead.
const full = this.o.team?.contextFull
if (full && window) {
const used = this.contextUsed()
if (used !== undefined && used >= window * LOOP.team.context.pause_at) {
const r = full(used, window)
if (r === "stop") return "stop"
if (r === "go") return
}
if (full(0, 0) !== undefined) return
}
const autoAt = this.o.compaction?.autoAt ?? 0.85 const autoAt = this.o.compaction?.autoAt ?? 0.85
if (!window || autoAt >= 1) return if (!window || autoAt >= 1) return
const limit = window * autoAt const limit = window * autoAt
@@ -481,6 +510,7 @@ export class Engine {
} }
this.autoCompactions++ this.autoCompactions++
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` }) this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
this.progress.set({ phase: "compacting" })
try { try {
await this.summarise(signal) await this.summarise(signal)
// The task is never left to the summary: the prompt that started this turn comes back word // The task is never left to the summary: the prompt that started this turn comes back word
@@ -507,11 +537,13 @@ export class Engine {
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who /** Time spent waiting for the user does not count against the wall-clock budget: somebody who
* thinks for ten minutes about one command has not spent the agent's time. */ * thinks for ten minutes about one command has not spent the agent's time. */
private async waiting<T>(p: Promise<T>): Promise<T> { private async waiting<T>(p: Promise<T>, what: "approval" | "question" | "plan" = "approval", tool?: string): Promise<T> {
const t0 = performance.now() const t0 = performance.now()
const back = this.progress.hold({ phase: "waiting_for_person", what, ...(tool ? { tool } : {}) })
try { try {
return await p return await p
} finally { } finally {
back()
this.turnWaitedMs += performance.now() - t0 this.turnWaitedMs += performance.now() - t0
} }
} }
@@ -611,6 +643,17 @@ export class Engine {
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m) if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
} }
/** What the person typed in this session, oldest first (subagents.md §2) — compacted messages
* included, as the store keeps them; never a note, a marked message or a machine turn. */
personSaid(): string[] {
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)]
}
/** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this
* device has not seen, as the client sent them (`said` on session/new). */
saidBefore: string[] = []
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed. /** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
* `meta`: the turn's id and what was attached, said on the `prompt` event. */ * `meta`: the turn's id and what was attached, said on the `prompt` event. */
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
@@ -618,13 +661,13 @@ export class Engine {
this.busy = true this.busy = true
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) }) this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) })
try { try {
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine) return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine, meta.maxSteps)
} finally { } finally {
this.busy = false this.busy = false
} }
} }
private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true, stepCap?: number): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
const { bus } = this.o const { bus } = this.o
this.abort = new AbortController() this.abort = new AbortController()
const signal = this.abort.signal const signal = this.abort.signal
@@ -648,7 +691,7 @@ export class Engine {
this.turnSteers = [] this.turnSteers = []
this.autoCompactions = 0 this.autoCompactions = 0
const toolMap = new Map(this.o.tools.map((t) => [t.name, t])) const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
const maxSteps = this.o.maxSteps ?? LOOP.steps.default const maxSteps = Math.min(this.o.maxSteps ?? LOOP.steps.default, stepCap ?? Infinity)
const started = performance.now() const started = performance.now()
this.turnWaitedMs = 0 this.turnWaitedMs = 0
this.turnOutputBytes = 0 this.turnOutputBytes = 0
@@ -666,7 +709,10 @@ export class Engine {
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` }) if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
} }
const wrapUp = step > maxSteps || ranOut !== undefined const wrapUp = step > maxSteps || ranOut !== undefined
if (!wrapUp) await this.manageContext(signal) if (!wrapUp && (await this.manageContext(signal)) === "stop") {
bus.emit({ type: "done", reason: "stop" })
return "stop"
}
// After compacting, so what was just sent is never folded into a summary. // After compacting, so what was just sent is never folded into a summary.
if (!wrapUp) this.deliverInbox() if (!wrapUp) this.deliverInbox()
bus.emit({ type: "step", n: step }) bus.emit({ type: "step", n: step })
@@ -809,11 +855,11 @@ export class Engine {
...this.o.toolCtx, ...this.o.toolCtx,
signal, signal,
backgroundNow: bgNow.signal, backgroundNow: bgNow.signal,
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined, question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id), "question") : undefined,
plan: { plan: {
get: () => this.plan, get: () => this.plan,
set: (p) => this.setPlan(p, "model"), set: (p) => this.setPlan(p, "model"),
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined, ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id), "plan") : undefined,
}, },
setMode: (m) => { setMode: (m) => {
this.mode = m this.mode = m
@@ -873,6 +919,11 @@ export class Engine {
// No dialect takes an image inside a tool result the same way; a user turn right after the // No dialect takes an image inside a tool result the same way; a user turn right after the
// results works everywhere. // results works everywhere.
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false }) if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
// Spec 3.4.0: a subagent that called team_finish has ended — nothing runs after that call.
if (this.o.team?.ended?.()) {
bus.emit({ type: "done", reason: "stop" })
return "stop"
}
if (signal.aborted) { if (signal.aborted) {
bus.emit({ type: "done", reason: "cancelled" }) bus.emit({ type: "done", reason: "cancelled" })
return "cancelled" return "cancelled"
@@ -943,6 +994,13 @@ export class Engine {
let edited: string | undefined let edited: string | undefined
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] } const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
let decision = evaluate(request, ctx) let decision = evaluate(request, ctx)
// Spec 3.4.0 (subagents.md §7): a subagent of the tree — itself, the main agent, or the person.
if (team?.permit) {
const { mode: _m, ...rest } = ctx
const v = team.permit(request, rest)
if (v.answer === "refused") return { result: { output: v.text, isError: true }, denied: true }
decision = { ...v.decision, action: v.answer === "run" ? "allow" : "ask" }
}
// Doom-loop guard: the same call three times running is asked about, whatever the mode. // Doom-loop guard: the same call three times running is asked about, whatever the mode.
const key = `${call.name} ${JSON.stringify(args)}` const key = `${call.name} ${JSON.stringify(args)}`
@@ -959,7 +1017,7 @@ export class Engine {
(diff) => ({ diff }), (diff) => ({ diff }),
(e) => ({ error: (e as Error).message }), (e) => ({ error: (e as Error).message }),
) )
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose })) const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }), "approval", tool.name)
if (reply.kind === "deny" && looping) if (reply.kind === "deny" && looping)
return { return {
result: { result: {
+240
View File
@@ -0,0 +1,240 @@
// Progress (harness spec 3.4.0, harness/progress.md): one fixed set of phases every client shows
// the same way. The object, its text from loop.json `progress.texts` and the one line drawn for it.
import { LOOP } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { duration } from "../duration.ts"
const P = LOOP.progress
export type Phase =
| "queued"
| "waiting_for_model"
| "loading_model"
| "reading_prompt"
| "thinking"
| "writing"
| "running_tool"
| "waiting_for_person"
| "waiting_for_subagents"
| "compacting"
| "retrying"
| "stopped"
| "failed"
/** The structured progress object: the phase, when it began (Unix ms), and its data. */
export type Progress = { phase: Phase; since: number } & {
ahead?: number
model?: string
reason?: string
done?: number
total?: number
rate?: number
tool?: string
what?: string
running?: number
in?: number
attempt?: number
}
export const PHASES = P.phases as readonly Phase[]
const fill = (t: string, v: Record<string, string | number>) => fillPrompt(t, Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])))
/** A call's short title as `what`: one line, at most what_max characters. */
export function shortWhat(s: string | undefined): string | undefined {
if (!s) return undefined
const line = s.split("\n")[0]!.trim()
if (!line) return undefined
return line.length > P.what_max ? `${line.slice(0, P.what_max - 1)}…` : line
}
/** The phase's text (progress.md §2), its fields filled; `now` for retrying's countdown. */
export function progressText(p: Progress, now = Date.now()): string {
const T = P.texts as Record<string, string>
switch (p.phase) {
case "queued":
return p.ahead !== undefined ? fill(T["queued.ahead"]!, { ahead: p.ahead }) : T.queued!
case "waiting_for_model":
return fill(T.waiting_for_model!, { model: p.model ?? "", reason: (P.reason as Record<string, string>)[p.reason ?? ""] ?? p.reason ?? "" })
case "loading_model":
return fill(T.loading_model!, { model: p.model ?? "" })
case "reading_prompt":
if (p.total === undefined || p.done === undefined || p.total <= 0) return T["reading_prompt.bare"]!
return fill(T.reading_prompt!, { done: p.done, total: p.total, percent: Math.floor((p.done * 100) / p.total) })
case "writing":
return p.rate !== undefined && Number.isFinite(p.rate) ? fill(T.writing!, { rate: p.rate.toFixed(1) }) : T["writing.bare"]!
case "running_tool":
return p.what ? fill(T.running_tool!, { tool: p.tool ?? "", what: p.what }) : fill(T["running_tool.bare"]!, { tool: p.tool ?? "" })
case "waiting_for_person": {
const W = P.what as Record<string, string>
const what = p.what === "approval" ? (p.tool ? fill(W.approval!, { tool: p.tool }) : W["approval.bare"]!) : (W[p.what ?? ""] ?? p.what ?? "")
return fill(T.waiting_for_person!, { what })
}
case "waiting_for_subagents":
return fill(T.waiting_for_subagents!, { running: p.running ?? 0 })
case "retrying": {
const left = Math.max(0, (p.in ?? 0) - Math.floor((now - p.since) / 1000))
return fill(T.retrying!, { in: left, reason: p.reason ?? "" })
}
case "stopped":
return p.reason ? fill(T["stopped.reason"]!, { reason: (P.stopped_reason as Record<string, string>)[p.reason] ?? p.reason }) : T.stopped!
case "failed":
return fill(T.failed!, { reason: p.reason ?? "" })
default:
return T[p.phase] ?? p.phase
}
}
/** The line a client draws (progress.md §3): the text and the time since it began, or the text
* alone for stopped and failed. */
export function progressLine(p: Progress, now = Date.now()): string {
const text = progressText(p, now)
if ((P.no_elapsed as string[]).includes(p.phase)) return text
return fill(P.line, { text, elapsed: duration(now - p.since, true) })
}
/** Whether two objects are the same phase for the same thing (so `since` is kept). */
export function samePhase(a: Progress | null | undefined, b: Omit<Progress, "since">): boolean {
if (!a || a.phase !== b.phase) return false
if (b.phase === "running_tool") return a.tool === b.tool && a.what === b.what
if (b.phase === "waiting_for_model" || b.phase === "loading_model") return a.model === b.model && a.reason === b.reason
if (b.phase === "waiting_for_person") return a.what === b.what && a.tool === b.tool
if (b.phase === "retrying") return a.attempt === b.attempt && a.reason === b.reason
return true
}
/** What a tool call does, in a line (`what` of running_tool): the command, the path, the query. */
export function callWhat(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined
const a = args as Record<string, unknown>
for (const k of ["command", "path", "file_path", "pattern", "query", "url", "description", "name"]) if (typeof a[k] === "string" && a[k]) return shortWhat(a[k] as string)
return undefined
}
type BusLike = { on(fn: (e: any) => void): () => void; emit(e: any): void }
/** The progress of one engine, derived from the events it emits (progress.md §4), and said on its
* bus as `phase` events: a new phase at once, a change of its data at most pace_per_second times a
* second. Explicit phases (compacting, waiting for the person or a model, queued) are `set` by
* whoever knows them. */
export class ProgressTracker {
current: Progress | null = null
private rate: number | undefined
private running = new Map<string, Omit<Progress, "since">>()
private lastEmit = 0
private pendingTimer: ReturnType<typeof setTimeout> | undefined
private error: string | undefined
private held: Progress | null = null
private off: () => void
constructor(
private bus: BusLike,
private now: () => number = Date.now,
) {
this.off = bus.on((e) => this.on(e))
}
dispose() {
this.off()
if (this.pendingTimer) clearTimeout(this.pendingTimer)
}
/** Set the phase (or null). Same phase for the same thing keeps `since`. */
set(next: Omit<Progress, "since"> | null) {
if (next === null) {
if (this.current === null) return
this.current = null
return this.emit(true)
}
if (samePhase(this.current, next)) {
const merged = { ...this.current!, ...next, since: this.current!.since } as Progress
if (JSON.stringify(merged) === JSON.stringify(this.current)) return
this.current = merged
return this.emit(false)
}
this.current = { ...next, since: this.now() } as Progress
this.emit(true)
}
/** A phase that waits on something outside the turn (the person, a model): set now, and the
* phase before it put back by the function returned. */
hold(next: Omit<Progress, "since">): () => void {
const before = this.current
this.held = before
this.set(next)
return () => {
if (this.current && this.current.phase === next.phase) {
this.current = before ? { ...before } : null
this.emit(true)
}
this.held = null
}
}
private emit(now: boolean) {
const t = this.now()
const gap = 1000 / LOOP.progress.pace_per_second
if (!now && t - this.lastEmit < gap) {
this.pendingTimer ??= setTimeout(() => {
this.pendingTimer = undefined
this.emit(true)
}, gap - (t - this.lastEmit))
return
}
if (this.pendingTimer) {
clearTimeout(this.pendingTimer)
this.pendingTimer = undefined
}
this.lastEmit = t
this.bus.emit({ type: "phase", progress: this.current ? { ...this.current } : null })
}
private on(e: any) {
switch (e.type) {
case "task":
if (e.state === "start") {
this.rate = undefined
this.running.clear()
this.error = undefined
}
return
case "step":
this.running.clear()
return this.set({ phase: "reading_prompt" })
case "progress":
return this.set({ phase: "reading_prompt", done: (e.cache ?? 0) + (e.processed ?? 0), total: e.total })
case "reasoning":
return this.set({ phase: "thinking" })
case "text":
case "tool_call_delta":
return this.set(this.rate !== undefined ? { phase: "writing", rate: this.rate } : { phase: "writing" })
case "usage":
if (!e.child && typeof e.rate === "number" && Number.isFinite(e.rate)) this.rate = e.rate
return
case "tool_start": {
const a = (e.args ?? {}) as Record<string, unknown>
const p: Omit<Progress, "since"> = e.name === "task" && a.background !== true ? { phase: "waiting_for_subagents", running: 1 } : { phase: "running_tool", tool: e.name, ...(callWhat(e.args) ? { what: callWhat(e.args) } : {}) }
this.running.set(e.id, p)
if (this.running.size === 1) this.set(p)
return
}
case "tool_end": {
this.running.delete(e.id)
const next = this.running.values().next()
if (!next.done) this.set(next.value)
return
}
case "notice":
if (e.retry) this.set({ phase: "retrying", reason: shortWhat(e.retry.reason) ?? "", in: e.retry.in, ...(e.retry.attempt ? { attempt: e.retry.attempt } : {}) })
return
case "error":
this.error = shortWhat(String(e.message ?? "")) ?? "error"
return
case "done":
this.running.clear()
if (e.reason === "stop") return this.set(null)
if (e.reason === "error") return this.set({ phase: "failed", reason: this.error ?? "error" })
return this.set({ phase: "stopped", reason: e.reason === "cancelled" ? "person" : e.reason })
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// Delegation (harness spec 3.4.0, subagents.md §7): a subagent's mode is its parent's or stricter,
// request options go with a stricter mode, and who decides a subagent's call — itself, the main
// agent (through a request), or the person. Pure rules over permission/evaluate.ts.
import { LOOP } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import type { Mode } from "../config/schema.ts"
import { evaluate, type Context as PermContext, type Decision, type PermissionRequest, type Rule, type ToolClass } from "../permission/evaluate.ts"
import MODES_JSON from "../../harness/permission/modes.json"
const TEXTS = LOOP.team.texts
const fill = (t: string, v: Record<string, string>) => fillPrompt(t, v)
/** Loosest first (permission/modes.json `order`). */
export const MODE_ORDER = MODES_JSON.order as Mode[]
const rank = (m: Mode) => MODE_ORDER.indexOf(m)
/** a is looser than b. */
export const looser = (a: Mode, b: Mode) => rank(a) < rank(b)
/** a is stricter than b. */
export const stricter = (a: Mode, b: Mode) => rank(a) > rank(b)
/** The stricter of the two. */
export const strictest = (a: Mode, b: Mode): Mode => (stricter(a, b) ? a : b)
export const REQUEST_KINDS = LOOP.team.request.kinds as ToolClass[]
/** team_add's / team_assign's mode and request options for a parent in `parentMode` (§7): the
* refusal, or the mode and options the subagent gets. */
export function checkMode(parentMode: Mode, mode: Mode | undefined, request: string[] | undefined): { error: string } | { mode: Mode; request: ToolClass[] } {
const m = mode ?? parentMode
if (looser(m, parentMode)) return { error: fill(TEXTS.wider, { parent_mode: parentMode }) }
const req = request ?? []
if (req.length && !stricter(m, parentMode)) return { error: TEXTS.request_not_narrower }
const bad = req.find((k) => !(REQUEST_KINDS as string[]).includes(k))
if (bad !== undefined) return { error: fill(TEXTS.request_kind, { kind: bad }) }
return { mode: m, request: [...new Set(req)] as ToolClass[] }
}
/** A grant the main agent gave (team_decide): a rule for this subagent alone. */
export interface Grant {
tool: string
/** The permission key of the tool (edit and write share `edit`). */
permission?: string
pattern: string
}
export type Verdict = { answer: "run"; decision: Decision } | { answer: "person"; decision: Decision } | { answer: "refused"; text: string; decision?: Decision }
/** Who decides a subagent's call (§7). `ctx` is the session's permission context (its rules and
* approvals); `mode` the subagent's, `main` the main agent's. A grant applies only where the main
* agent's own mode would allow the call. */
export function whoDecides(
req: PermissionRequest,
o: { ctx: Omit<PermContext, "mode">; mode: Mode; main: Mode; request: readonly string[]; grants: readonly Grant[]; unattended?: boolean },
): Verdict {
const mainD = evaluate(req, { ...o.ctx, mode: o.main })
const grantRules: Rule[] = mainD.action === "allow" ? o.grants.map((g) => ({ permission: g.permission ?? g.tool, pattern: g.pattern, action: "allow" as const })) : []
const own = evaluate(req, { ...o.ctx, mode: o.mode, rules: [...o.ctx.rules, ...grantRules] })
if (own.action === "allow") return { answer: "run", decision: own }
if (own.action === "deny") return { answer: "refused", text: `Denied: ${own.reason}`, decision: own }
if (mainD.action === "allow") return { answer: "refused", text: o.request.includes(req.class) ? TEXTS.requestable : TEXTS.not_delegated }
if (mainD.action === "deny") return { answer: "refused", text: `Denied: ${mainD.reason}`, decision: mainD }
if (o.unattended) return { answer: "refused", text: "Refused: nobody can approve this in an unattended session.", decision: own }
return { answer: "person", decision: own }
}
/** team_request's own check: the tool's risk class must be one of the options. */
export function requestRefusal(tool: string, cls: ToolClass | undefined, request: readonly string[]): string | undefined {
if (cls && request.includes(cls)) return undefined
return fill(TEXTS.not_requestable, { tool, kinds: request.join(", ") || "none" })
}
/** The main agent made stricter: every subagent looser than its parent is made as strict as it,
* down the tree (§7). `agents` in tree order; returns each one's mode after. */
export function tighten(main: Mode, agents: { name: string; parent: string; mode: Mode }[], lead = LOOP.team.names.lead): Record<string, Mode> {
const modes: Record<string, Mode> = { [lead]: main }
const out: Record<string, Mode> = {}
const pending = [...agents]
// Parents first: an agent's parent is the lead or one listed before it, but tolerate any order.
for (let guard = 0; pending.length && guard < 1000; guard++) {
const i = pending.findIndex((a) => modes[a.parent] !== undefined)
if (i < 0) break
const a = pending.splice(i, 1)[0]!
modes[a.name] = out[a.name] = strictest(a.mode, modes[a.parent]!)
}
return out
}
+107 -6
View File
@@ -1,5 +1,5 @@
// A lead session's team at work (harness spec 3.2.0, team.md): the rules of team.ts driven by real // A lead session's team at work (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13): the
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job // rules of team.ts driven by real turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens // machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches // through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
// a working agent at its next step, an agent is stopped. // a working agent at its next step, an agent is stopped.
@@ -8,10 +8,14 @@ import { TOOL_SPECS } from "../harness.ts"
import type { Engine } from "../session/engine.ts" import type { Engine } from "../session/engine.ts"
import type { Tool } from "../tool/tool.ts" import type { Tool } from "../tool/tool.ts"
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts" import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
import type { Mode } from "../config/schema.ts"
import type { ToolClass } from "../permission/evaluate.ts"
/** A member's engine, as the app made it. */ /** A member's engine, as the app made it. */
export interface MemberHandle { export interface MemberHandle {
engine: Engine engine: Engine
/** Its one final turn after its context filled (spec 3.4.0 §9), with this text, few steps. */
final?(text: string): void
/** Its next turn, with this text as the prompt (its context intact). */ /** Its next turn, with this text as the prompt (its context intact). */
run(text: string): void run(text: string): void
/** Stop it where it is; nothing more comes from it. */ /** Stop it where it is; nothing more comes from it. */
@@ -25,10 +29,12 @@ export interface RuntimeOptions {
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */ /** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
spawnMember(member: TeamAgent, text: string): void spawnMember(member: TeamAgent, text: string): void
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */ /** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
lead: { note(text: string): void; wake(text: string): void } lead: { note(text: string): void; wake(text: string): void; /** Spec 3.4.0: its progress while it waits for a slot. */ waiting?(model: string): void }
/** An unknown agent or model, answered as task answers it; undefined when both are known. The /** An unknown agent or model, answered as task answers it; undefined when both are known. The
* model's ref as the session knows it. */ * model's ref as the session knows it. */
resolve?(agent: string, model: string | undefined): { error: string } | { model: string } resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
/** A tool's risk class and permission key, by its name (team_request, team_decide). */
tools?: () => readonly Tool[]
} }
export class TeamRuntime { export class TeamRuntime {
@@ -36,6 +42,11 @@ export class TeamRuntime {
private members = new Map<string, MemberHandle>() private members = new Map<string, MemberHandle>()
/** The lead's turn waiting for a slot: resolved when it is given one. */ /** The lead's turn waiting for a slot: resolved when it is given one. */
private leadWaiter: (() => void) | undefined private leadWaiter: (() => void) | undefined
/** Spec 3.4.0: subagents that ended by their own team_finish (their turn stops after the call),
* and the final turn waiting for one whose context filled. */
private ended = new Set<string>()
private finals = new Map<string, string>()
private pace: ReturnType<typeof setTimeout> | undefined
constructor(private o: RuntimeOptions) { constructor(private o: RuntimeOptions) {
this.team = o.team this.team = o.team
@@ -47,6 +58,8 @@ export class TeamRuntime {
start: (name, text) => this.start(name, text), start: (name, text) => this.start(name, text),
deliver: (name, text) => this.deliver(name, text), deliver: (name, text) => this.deliver(name, text),
stop: (name) => this.stopped(name), stop: (name) => this.stopped(name),
end: (name) => void this.ended.add(name),
final: (name, text) => void this.finals.set(name, text),
} }
} }
@@ -59,8 +72,82 @@ export class TeamRuntime {
this.changed() this.changed()
} }
/** Said on the lead's bus as `team`, at most four times a second (acp.md `team` event). */
private changed() { private changed() {
// A change inside the pace window is said once at its end, so the last state always goes out.
if (this.pace) return void (this.trailing = true)
this.o.bus.emit({ type: "team" }) this.o.bus.emit({ type: "team" })
this.pace = setTimeout(() => {
this.pace = undefined
if (this.trailing) {
this.trailing = false
this.changed()
}
}, 1000 / 4)
}
private trailing = false
/** Something changed that a reader must see now (the tools' answers, tests): said at once. */
private changedNow() {
if (this.pace) clearTimeout(this.pace)
this.pace = undefined
this.trailing = false
this.o.bus.emit({ type: "team" })
}
/** A tool's risk class, for team_request. */
classOf(tool: string): ToolClass | undefined {
const spec = TOOL_SPECS[tool]?.risk
if (spec) return spec as ToolClass
const t = this.o.tools?.().find((x) => x.name === tool)
try {
return t ? (t.permission({} as never, {} as never).class as ToolClass) : undefined
} catch {
return "execute"
}
}
/** A tool's permission key (edit and write share `edit`), for a grant. */
permissionKeyOf(tool: string): string | undefined {
return this.o.tools?.().find((x) => x.name === tool)?.access ?? tool
}
/** Every subagent's engine in its team's mode (spec 3.4.0 §7: the main agent made stricter, an
* assignment). */
syncModes() {
for (const [name, h] of this.members) {
const m = this.team.get(name)?.mode
if (m && h.engine.mode !== m) h.engine.mode = m
}
}
/** The main agent's mode changed (the person, plan approved): stricter reaches down the tree. */
mainMode(mode: Mode) {
this.team.setMainMode(mode)
this.syncModes()
this.changed()
}
/** Spec 3.4.0: whether this subagent ended by its own call (its engine stops after the step). */
hasEnded(name: string): boolean {
return this.ended.has(name)
}
/** Spec 3.4.0 §9: its context reached pause_at. "stop" the first time (it is paused, its final
* turn follows), "go" in that final turn (no compaction, it carries on). */
contextFull(name: string): "stop" | "go" {
const a = this.team.get(name)
if (!a || a.state === "paused") return "go"
this.team.pause(name)
this.changedNow()
return "stop"
}
/** What a member's run should do when its turn ended: its final turn (context full), or report. */
takeFinal(name: string): string | undefined {
const t = this.finals.get(name)
this.finals.delete(name)
return t
} }
/** Something the tools did changed the team: said, for whoever shows it. */ /** Something the tools did changed the team: said, for whoever shows it. */
@@ -70,7 +157,7 @@ export class TeamRuntime {
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the /** team_add for `as`: an unknown agent or model is answered as task answers it, after the
* team's own refusals (the name, the goal, the size), which come first. */ * team's own refusals (the name, the goal, the size), which come first. */
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } { add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
const agent = a.agent ?? "general" const agent = a.agent ?? "general"
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model } const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
// The team's refusals first; an added member needs a known agent and model. // The team's refusals first; an added member needs a known agent and model.
@@ -111,6 +198,7 @@ export class TeamRuntime {
private stopped(name: string) { private stopped(name: string) {
const h = this.members.get(name) const h = this.members.get(name)
this.members.delete(name) this.members.delete(name)
this.finals.delete(name)
h?.cancel() h?.cancel()
this.changed() this.changed()
} }
@@ -125,6 +213,7 @@ export class TeamRuntime {
return true return true
} }
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` }) this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
this.o.lead.waiting?.(this.team.model)
this.changed() this.changed()
return await new Promise<boolean>((resolve) => { return await new Promise<boolean>((resolve) => {
const onAbort = () => { const onAbort = () => {
@@ -147,8 +236,21 @@ export class TeamRuntime {
this.changed() this.changed()
} }
/** A member's turn ended: its reply is its report; messages it was given too late wake it again. */ /** A member's turn ended: its reply is its report; messages it was given too late wake it again.
* Spec 3.4.0: one that ended by its own team_finish is let go; one whose context filled gets its
* final turn first. */
memberDone(name: string, reply: string, reason: string, leftover: string[]) { memberDone(name: string, reply: string, reason: string, leftover: string[]) {
if (this.ended.delete(name)) {
this.members.delete(name)
this.changed()
return
}
const final = this.takeFinal(name)
if (final !== undefined && this.team.get(name)?.state === "paused") {
this.members.get(name)?.final?.(final)
this.changed()
return
}
if (!this.team.get(name)) return if (!this.team.get(name)) return
this.team.finish(name, reply, reason) this.team.finish(name, reply, reason)
if (leftover.length) this.team.undelivered(name, leftover) if (leftover.length) this.team.undelivered(name, leftover)
@@ -157,7 +259,6 @@ export class TeamRuntime {
/** What a call answers before its permission check, for the agent `as`. */ /** What a call answers before its permission check, for the agent `as`. */
refuse(as: string, tool: Tool, args: unknown): string | undefined { refuse(as: string, tool: Tool, args: unknown): string | undefined {
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it // A tool the spec does not have (an MCP server's) by the class its call carries: read when it
// says readOnlyHint, interact for the server's memory, else execute. // says readOnlyHint, interact for the server's memory, else execute.
let risk: string | undefined let risk: string | undefined
+101
View File
@@ -0,0 +1,101 @@
// Subagents (harness spec 3.3.0, harness/subagents.md): which model a subagent — a task subagent or
// a team's member — runs on, and whether subagents are offered at all. The rules alone, without an
// engine: the conformance area `subagents` drives them, and the app (spawn, team_add) asks them.
import { LOOP } from "../harness.ts"
import type { Message } from "../provider/types.ts"
import { parallelFrom, type Capacity } from "./team.ts"
export const SUBAGENTS = LOOP.subagents
const TEXTS = SUBAGENTS.texts
/** A model the person may use: its served id (the ref), its id at its connection, its display name. */
export interface ModelInfo {
ref: string
id?: string
name?: string
capacity: Capacity
}
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const BEFORE = new RegExp(`${SUBAGENTS.named.before}$`, "u")
const AFTER = new RegExp(`^(?:${SUBAGENTS.named.after})`, "u")
/** The names a model goes by: the served id, the part after its provider, the display name. */
export function namesOf(m: Pick<ModelInfo, "ref" | "id" | "name">): string[] {
const out = [m.ref]
if (m.id) out.push(m.id)
const slash = m.ref.lastIndexOf("/")
if (slash >= 0) out.push(m.ref.slice(slash + 1))
if (m.name?.trim()) out.push(m.name.trim())
return [...new Set(out.filter(Boolean))]
}
/** Whether `name` occurs in `text` as a whole word, without case (subagents.md §2). White space in
* the name matches any run of white space. */
export function occursAsWord(text: string, name: string): boolean {
const pattern = name
.trim()
.split(/\s+/)
.map(escape)
.join("\\s+")
if (!pattern) return false
const re = new RegExp(pattern, "giu")
for (let m = re.exec(text); m; m = re.exec(text)) {
const before = text.slice(0, m.index)
const after = text.slice(m.index + m[0].length)
if (!BEFORE.test(before) && !AFTER.test(after)) return true
re.lastIndex = m.index + 1
}
return false
}
/** Whether the person named the model in any of the messages they typed. */
export function named(m: Pick<ModelInfo, "ref" | "id" | "name">, said: readonly string[]): boolean {
const names = namesOf(m)
return said.some((t) => names.some((n) => occursAsWord(t, n)))
}
/** What the person typed in a session, oldest first: their prompts and what they sent mid-turn
* (after `steerMark`) — never a note, a job's or a team's marked message, or a machine turn. */
export function personTexts(messages: readonly Message[], steerMark: string): string[] {
const out: string[] = []
for (const m of messages) {
if (m.role !== "user" || m.machine) continue
const first = m.parts[0]
if (!first || first.type !== "text") continue
if (m.turn === false) {
if (first.text.startsWith(steerMark)) out.push(first.text.slice(steerMark.length).trim())
continue
}
out.push(first.text)
}
return out
}
/** Whether a model can be its own session's subagent: it serves two requests at once, or has no limit. */
export function servesOwn(c: Capacity): boolean {
return parallelFrom(c) >= 2
}
/** subagents.md §4: whether subagents are offered for a session on `session`, given the models the
* person may use, and whether the session's model can serve its own. */
export function offer(session: ModelInfo, models: readonly ModelInfo[], subagents = true): { offered: boolean; own: boolean } {
const own = servesOwn(session.capacity)
const group = session.capacity.group
const other = models.some((m) => m.ref !== session.ref && (group === undefined || m.capacity.group !== group))
return { offered: subagents && (own || other), own }
}
/** subagents.md §2, §3: the model a subagent runs on — the session's, or `wanted` (already resolved
* to a model the person may use) — or the refusal, word for word. */
export function subagentModel(session: ModelInfo, wanted: ModelInfo | undefined, said: readonly string[]): { model: string } | { refused: string } {
const m = wanted ?? session
if (m.ref !== session.ref) {
if (!named(m, said)) return { refused: TEXTS.not_named }
const group = session.capacity.group
if (group !== undefined && m.capacity.group === group) return { refused: TEXTS.same_group }
return { model: m.ref }
}
if (!servesOwn(session.capacity)) return { refused: TEXTS.one_request }
return { model: session.ref }
}
+462 -49
View File
@@ -1,5 +1,7 @@
// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives // Teams (harness spec 3.2.0, harness/team.md; 3.4.0, harness/subagents.md §5–§13): the main agent
// what, the size the person allows, a manager's refusals, and the slots of the model they run on. // (`lead`) and its subagents, their room, who receives what, the size the person allows, a
// manager's refusals, the slots of the model they run on — and from 3.4.0 the tree only the main
// agent builds, modes and request options, findings, and a subagent's end.
// This file is the rules alone, without an engine: the conformance area `team` drives it step by // This file is the rules alone, without an engine: the conformance area `team` drives it step by
// step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's // step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's
// turn starts (it was given a slot), when a message reaches an agent that is working, and when an // turn starts (it was given a slot), when a message reaches an agent that is working, and when an
@@ -9,14 +11,42 @@ import { fillPrompt } from "../prompt/fill.ts"
import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts" import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts"
import { BUILTIN_HARDLINE } from "../permission/hardline.ts" import { BUILTIN_HARDLINE } from "../permission/hardline.ts"
import { duration } from "../duration.ts" import { duration } from "../duration.ts"
import type { Mode } from "../config/schema.ts"
import type { ToolClass } from "../permission/evaluate.ts"
import { checkMode, strictest, type Grant } from "./delegation.ts"
import type { Progress } from "../session/progress.ts"
export const TEAM = LOOP.team export const TEAM = LOOP.team
const TEXTS = TEAM.texts const TEXTS = TEAM.texts
const LEAD = TEAM.names.lead const LEAD = TEAM.names.lead
export const TEAM_TOOLS = ["team_add", "team_read", "team_send", "team_stop"] as const export const TEAM_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami"] as const
/** Spec 3.4.0 (subagents.md §8): the main agent's alone. */
export const MAIN_ONLY = TEAM.main_only.tools as readonly string[]
/** The main agent's team tools, and a subagent's (with team_request only when it has request options). */
export const LEAD_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_read", "team_replace", "team_send", "team_stop", "team_whoami"] as const
export const MEMBER_TOOLS = ["team_findings", "team_finish", "team_read", "team_send", "team_whoami"] as const
export function memberTools(a: { request: readonly string[] }): string[] {
return [...MEMBER_TOOLS, ...(a.request.length ? ["team_request"] : [])].sort()
}
const NAME = new RegExp(TEAM.names.pattern) const NAME = new RegExp(TEAM.names.pattern)
export type AgentState = "working" | "waiting" | "finished" | "idle" export type AgentState = "working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped"
export type EndState = "done" | "out_of_context" | "stopped"
export interface Finding {
n: number
text: string
time: number
}
export interface Request {
id: string
from: string
tool: string
pattern: string
why: string
open: boolean
}
/** What a model's server can do at once (models/schema.json capacity). */ /** What a model's server can do at once (models/schema.json capacity). */
export interface Capacity { export interface Capacity {
@@ -32,11 +62,26 @@ export function parallelFrom(c: Capacity | undefined): number {
} }
export interface TeamAgent { export interface TeamAgent {
/** `lead` for the main agent; s1, s2, … for subagents, never reused (spec 3.4.0). */
id: string
name: string name: string
/** Its position (team_add's role). */
role: string role: string
/** Its task, as given and as assigned since. */
task: string
mode: Mode
/** Request options (spec 3.4.0 §7). */
request: ToolClass[]
/** What the main agent allowed it (team_decide). */
grants: Grant[]
findings: Finding[]
/** Its context use, as its engine last said it. */
context?: { used: number; window: number }
/** Its current progress (progress.md). */
progress?: Progress | null
/** Who added it; null for the lead. */ /** Who added it; null for the lead. */
manager: string | null manager: string | null
/** May add members of its own (team_add's `lead`); the lead may. */ /** A sub-manager (team_add's `lead`), or the main agent: subagents may be placed under it. */
leads: boolean leads: boolean
/** The team's goal as it was told it (the lead's: the last one it gave). */ /** The team's goal as it was told it (the lead's: the last one it gave). */
goal: string goal: string
@@ -54,6 +99,13 @@ export interface TeamAgent {
ended?: number ended?: number
/** Added in this order (tree order among siblings). */ /** Added in this order (tree order among siblings). */
seq: number seq: number
/** How it ended, and when (an ended subagent stays in the tree's `ended`). */
endState?: EndState
endedAt?: number
/** The sub-manager it was under before that one ended (team_replace puts it back). */
movedFrom?: string
/** The device's job that runs it (its transcript, read-only). */
jobId?: string
} }
export interface RoomMessage { export interface RoomMessage {
@@ -65,6 +117,38 @@ export interface RoomMessage {
text: string text: string
time: number time: number
report: boolean report: boolean
/** Spec 3.4.0: what it is — a message, a report, an assignment, a request, a decision. */
kind: RoomKind
}
export type RoomKind = "message" | "report" | "assignment" | "request" | "decision"
/** A row of the tree (subagents.md §12). */
export interface TreeAgent {
id: string
name: string
position: string
parent: string | null
state: AgentState
model: string
mode: Mode
request: string[]
manager: boolean
task?: string
context?: { used: number; window: number }
progress: Progress | null
findings: number
messages: number
ended?: number
}
export interface TreeInfo {
min: number
max: number
size: number
open_slots: number
agents: TreeAgent[]
ended: TreeAgent[]
} }
export interface TeamEffects { export interface TeamEffects {
@@ -75,6 +159,11 @@ export interface TeamEffects {
deliver(name: string, text: string): void deliver(name: string, text: string): void
/** It left the team: its turn stops where it is, its background commands with it. */ /** It left the team: its turn stops where it is, its background commands with it. */
stop(name: string): void stop(name: string): void
/** Spec 3.4.0: it ended by its own team_finish — its turn stops after that call. */
end?(name: string): void
/** Spec 3.4.0: its context is full — its turn stops after this step, and its one final turn
* starts with `text`. */
final?(name: string, text: string): void
} }
export interface TeamOptions { export interface TeamOptions {
@@ -86,12 +175,19 @@ export interface TeamOptions {
/** The lead's model (the chat's), and every model's capacity. */ /** The lead's model (the chat's), and every model's capacity. */
model: string model: string
capacity: (model: string) => Capacity capacity: (model: string) => Capacity
/** Spec 3.3.0 (subagents.md §4): every model the person may use, by ref — a model off the lead's
* one-model server can serve as a subagent where the lead's model cannot. None: only the lead's. */
models?: () => readonly string[]
effects?: TeamEffects effects?: TeamEffects
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */ /** A bash command a manager may still run: one the default rules alone allow in manual mode. */
readingBash?: (command: string) => boolean readingBash?: (command: string) => boolean
now?: () => number now?: () => number
/** The lead's state as the team begins: working (its first prompt runs) or idle. */ /** The lead's state as the team begins: working (its first prompt runs) or idle. */
leadState?: "working" | "idle" leadState?: "working" | "idle"
/** The main agent's mode (the session's). Default: auto. */
mode?: Mode
/** Steps a turn may take: the main agent's and a subagent's (for team_whoami). */
steps?: { main: number; sub: number }
} }
/** The default rules' answer for a command in manual mode, with no rules of the person's. */ /** The default rules' answer for a command in manual mode, with no rules of the person's. */
@@ -111,10 +207,10 @@ export class Team {
model: string model: string
private agents = new Map<string, TeamAgent>() private agents = new Map<string, TeamAgent>()
room: RoomMessage[] = [] room: RoomMessage[] = []
/** `task` helpers running in the team: who started each, and on which model. */ /** `task` subagents running in the team: who started each, and on which model. */
private helpers: { id: number; by: string; model: string }[] = [] private tasks: { id: number; by: string; model: string }[] = []
private seq = 0 private seq = 0
private helperSeq = 0 private taskSeq = 0
/** Whether the team is offered, fixed as the prompt is made (§2). */ /** Whether the team is offered, fixed as the prompt is made (§2). */
offered = false offered = false
/** The setting as the lead was last told it. */ /** The setting as the lead was last told it. */
@@ -122,6 +218,11 @@ export class Team {
/** Members the person stopped, not yet said to the lead. */ /** Members the person stopped, not yet said to the lead. */
private stoppedByPerson: string[] = [] private stoppedByPerson: string[] = []
private waits = 0 private waits = 0
/** Subagents that ended (stopped, done, out of context), in the order they ended. */
ended: TeamAgent[] = []
private idSeq = 0
private reqSeq = 0
requests: Request[] = []
constructor(private o: TeamOptions) { constructor(private o: TeamOptions) {
this.cap = o.cap ?? TEAM.agents.cap this.cap = o.cap ?? TEAM.agents.cap
@@ -130,7 +231,7 @@ export class Team {
this.min = TEAM.agents.min this.min = TEAM.agents.min
this.max = TEAM.agents.max this.max = TEAM.agents.max
this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max) this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max)
this.agents.set(LEAD, { name: LEAD, role: TEAM.read.lead_role, manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ }) this.agents.set(LEAD, { id: LEAD, name: LEAD, role: TEAM.read.lead_role, task: "", mode: o.mode ?? "auto", request: [], grants: [], findings: [], manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ })
this.fix() this.fix()
} }
@@ -152,7 +253,7 @@ export class Team {
/** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */ /** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */
static check(min: number, max: number, cap: number): string | undefined { static check(min: number, max: number, cap: number): string | undefined {
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `agents: 1 ≤ min ≤ max ≤ ${cap} (the cap) — whole numbers; min ${min}, max ${max} is outside` if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `Subagents: 1 ≤ Min ≤ Max ≤ ${cap} (the cap), whole numbers, this agent included; Min ${min}, Max ${max} is outside`
return undefined return undefined
} }
@@ -165,13 +266,22 @@ export class Team {
return parallelFrom(this.o.capacity(this.model)) return parallelFrom(this.o.capacity(this.model))
} }
/** §2: offered when subagents are on and the chat's model serves two at once — fixed as the /** Spec 3.3.0 (subagents.md §4): whether a model could serve as a subagent now — the lead's own,
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */ * with two slots or more, or another model off its one-model server — and whether it is the own. */
couldServe(): { any: boolean; own: boolean } {
const own = this.parallelNow() >= 2
const group = this.o.capacity(this.model).group
const other = (this.o.models?.() ?? []).some((r) => r !== this.model && (group === undefined || this.o.capacity(r).group !== group))
return { any: own || other, own }
}
/** §2: offered when subagents are on and a model could serve as a subagent — fixed as the prompt
* is made (and made again on a model switch); `offered` is a kept prompt's decision. */
fix(model = this.model, offered?: boolean): boolean { fix(model = this.model, offered?: boolean): boolean {
this.model = model this.model = model
const lead = this.agents.get(LEAD) const lead = this.agents.get(LEAD)
if (lead) lead.model = model if (lead) lead.model = model
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2) this.offered = offered ?? (this.subagents && this.couldServe().any)
return this.offered return this.offered
} }
@@ -207,9 +317,9 @@ export class Team {
return this.agents.size > 1 return this.agents.size > 1
} }
/** §3: the lead, every member, and every running `task` helper. */ /** §3: the lead, every member, and every running `task` subagent. */
size(): number { size(): number {
return this.agents.size + this.helpers.length return this.agents.size + this.tasks.length
} }
/** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */ /** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */
@@ -222,8 +332,9 @@ export class Team {
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */ /** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
unavailable(): string | undefined { unavailable(): string | undefined {
if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined const could = this.couldServe().any
return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent if ((this.max >= 2 && could) || this.hasMembers()) return undefined
return !could ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent
} }
/** §3: what `task` answers in a team, or undefined when it may run (and then counts). */ /** §3: what `task` answers in a team, or undefined when it may run (and then counts). */
@@ -250,8 +361,14 @@ export class Team {
/** What a call by `by` answers before it runs — the team tools' requirement, task's size, a /** What a call by `by` answers before it runs — the team tools' requirement, task's size, a
* manager's work — or undefined when it goes on to its own checks (permission first). */ * manager's work — or undefined when it goes on to its own checks (permission first). */
refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined { refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined {
if ((TEAM_TOOLS as readonly string[]).includes(tool)) return this.offered ? this.unavailable() : TEAM.unavailable.one_agent if ((TEAM_TOOLS as readonly string[]).includes(tool)) {
if (tool === "task") return this.taskRefusal() const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no
// Spec 3.4.0 (subagents.md §6): only the main agent adds, assigns, replaces, stops, decides.
if (by !== LEAD && MAIN_ONLY.includes(tool)) return TEXTS.only_main
return undefined
}
if (tool === "task") return by !== LEAD ? TEXTS.only_main : this.taskRefusal()
if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager
return undefined return undefined
} }
@@ -259,14 +376,14 @@ export class Team {
// ── slots ─────────────────────────────────────────────────────────────────── // ── slots ───────────────────────────────────────────────────────────────────
private busyOn(model: string): number { private busyOn(model: string): number {
return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.helpers.filter((h) => h.model === model).length return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.tasks.filter((h) => h.model === model).length
} }
private free(model: string): boolean { private free(model: string): boolean {
const group = this.o.capacity(model).group const group = this.o.capacity(model).group
if (group !== undefined) { if (group !== undefined) {
for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false
for (const h of this.helpers) if (h.model !== model && this.o.capacity(h.model).group === group) return false for (const h of this.tasks) if (h.model !== model && this.o.capacity(h.model).group === group) return false
} }
return this.busyOn(model) < parallelFrom(this.o.capacity(model)) return this.busyOn(model) < parallelFrom(this.o.capacity(model))
} }
@@ -299,22 +416,22 @@ export class Team {
return lead.state === "working" return lead.state === "working"
} }
/** A `task` helper started by `by` (it counts and holds a slot until helperDone). */ /** A `task` subagent started by `by` (it counts and holds a slot until taskDone). */
helperStart(by: string, model = this.model): number { taskStart(by: string, model = this.model): number {
const id = ++this.helperSeq const id = ++this.taskSeq
this.helpers.push({ id, by, model }) this.tasks.push({ id, by, model })
return id return id
} }
helperDone(id: number) { taskDone(id: number) {
this.helpers = this.helpers.filter((h) => h.id !== id) this.tasks = this.tasks.filter((h) => h.id !== id)
this.fillSlots() this.fillSlots()
} }
/** The first helper `by` started, done (the conformance area's `helper_done`). */ /** The first task subagent `by` started, done (the conformance area's `task_done`). */
helperDoneBy(by: string) { taskDoneBy(by: string) {
const h = this.helpers.find((x) => x.by === by) const h = this.tasks.find((x) => x.by === by)
if (h) this.helperDone(h.id) if (h) this.taskDone(h.id)
} }
// ── delivery ──────────────────────────────────────────────────────────────── // ── delivery ────────────────────────────────────────────────────────────────
@@ -323,6 +440,8 @@ export class Team {
private deliver(name: string, text: string) { private deliver(name: string, text: string) {
const a = this.agents.get(name) const a = this.agents.get(name)
if (!a) return if (!a) return
// Spec 3.4.0 §9: a paused subagent is given nothing new; the message stays in the room.
if (a.state === "paused") return
this.delivered.push({ name, text }) this.delivered.push({ name, text })
if (a.state === "working") return this.o.effects?.deliver(name, text) if (a.state === "working") return this.o.effects?.deliver(name, text)
a.inbox.push(text) a.inbox.push(text)
@@ -332,10 +451,11 @@ export class Team {
/** What was delivered, to whom, since this was last read (for a front end and the conformance). */ /** What was delivered, to whom, since this was last read (for a front end and the conformance). */
delivered: { name: string; text: string }[] = [] delivered: { name: string; text: string }[] = []
private post(from: string, to: string[], recipients: string[], text: string, report: boolean): RoomMessage { private post(from: string, to: string[], recipients: string[], text: string, report: boolean, kind: RoomKind = report ? "report" : "message", id = ""): RoomMessage {
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report } const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report, kind }
this.room.push(m) this.room.push(m)
const mark = fill(report ? TEAM.marks.report : TEAM.marks.message, { from, to: to.join(", ") }) const marks = TEAM.marks as Record<string, string>
const mark = fill(marks[kind]!, { from, to: to.join(", "), id })
for (const r of recipients) this.deliver(r, `${mark}\n${text}`) for (const r of recipients) this.deliver(r, `${mark}\n${text}`)
return m return m
} }
@@ -343,32 +463,49 @@ export class Team {
// ── the tools ─────────────────────────────────────────────────────────────── // ── the tools ───────────────────────────────────────────────────────────────
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */ /** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
wouldRefuse(by: string, a: { name: string; goal?: string }): string | undefined { wouldRefuse(by: string, a: { name: string; goal?: string; parent?: string; mode?: Mode; request?: string[] }): string | undefined {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no if (no) return no
if (by !== LEAD) return TEXTS.only_main
if (a.name === "all") return TEXTS.all_name if (a.name === "all") return TEXTS.all_name
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name }) if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
if (this.agents.has(a.name)) return fill(TEXTS.taken, { name: a.name }) if (this.agents.has(a.name) || a.name === TEAM.names.parent) return fill(TEXTS.taken, { name: a.name })
if (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal if (!this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max }) if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
return undefined return this.placeRefusal(a.parent, a.mode, a.request)
}
/** Spec 3.4.0 (§6, §7): `parent` must be the lead or a sub-manager; the mode no looser than its. */
private placeRefusal(parent: string | undefined, mode: Mode | undefined, request: string[] | undefined): string | undefined {
const p = this.agents.get(parent ?? LEAD)
if (!p) return fill(TEXTS.unknown, { name: parent!, names: this.names() })
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
const m = checkMode(p.mode, mode, request)
return "error" in m ? m.error : undefined
} }
/** team_add (§4). `brief` is what the member's first turn is given. */ /** team_add (§4). `brief` is what the member's first turn is given. */
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } { add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
const no = this.wouldRefuse(by, a) const no = this.wouldRefuse(by, a)
if (no) return { text: no } if (no) return { text: no }
const lead = this.agents.get(LEAD)! const lead = this.agents.get(LEAD)!
const goal = a.goal?.trim() const goal = a.goal?.trim()
const caller = this.agents.get(by) ?? lead const parent = this.agents.get(a.parent ?? LEAD)!
if (by === LEAD && goal) lead.goal = goal if (goal) lead.goal = goal
const placed = checkMode(parent.mode, a.mode, a.request) as { mode: Mode; request: ToolClass[] }
const member: TeamAgent = { const member: TeamAgent = {
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
name: a.name, name: a.name,
role: a.role.trim().split("\n")[0]!, role: a.role.trim().split("\n")[0]!,
manager: by, task: a.task,
mode: placed.mode,
request: placed.request,
grants: [],
findings: [],
manager: parent.name,
leads: a.lead === true, leads: a.lead === true,
goal: goal || caller.goal, goal: goal || parent.goal || lead.goal,
model: a.model ?? caller.model, model: a.model ?? lead.model,
agent: a.agent ?? "general", agent: a.agent ?? "general",
state: "finished", state: "finished",
inbox: [], inbox: [],
@@ -386,7 +523,13 @@ export class Team {
send(by: string, a: { to: string[] | string; message: string }): string { send(by: string, a: { to: string[] | string; message: string }): string {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no if (no) return no
const list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)] let list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)]
// Spec 3.4.0: `parent` is the sender's parent.
if (list.includes(TEAM.names.parent)) {
const mgr = this.agents.get(by)?.manager
if (!mgr) return TEXTS.no_parent
list = [...new Set(list.map((n) => (n === TEAM.names.parent ? mgr : n)))]
}
const all = list.includes("all") const all = list.includes("all")
const others = [...this.agents.keys()].filter((n) => n !== by) const others = [...this.agents.keys()].filter((n) => n !== by)
if (all) { if (all) {
@@ -439,12 +582,21 @@ export class Team {
fill(R.agent, { fill(R.agent, {
indent: " ".repeat(depth(a)), indent: " ".repeat(depth(a)),
name: a.name, name: a.name,
id: a.id,
role: a.role, role: a.role,
state: R.states[a.state], state: R.states[a.state],
model: a.model, model: a.model,
mode: a.request.length ? `${a.mode} ${fill(R.request, { kinds: a.request.join(", ") })}` : a.mode,
context: a.context && a.context.window > 0 ? fill(R.context, { percent: Math.floor((a.context.used * 100) / a.context.window) }) : "—",
last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—", last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—",
}), }),
) )
const open = this.openSlots()
if (open) lines.push(fill(R.slots, { n: open }))
if (this.ended.length) {
lines.push(R.ended)
for (const a of this.ended) lines.push(fill(R.ended_agent, { name: a.name, id: a.id, role: a.role, state: R.states[a.state], findings: a.findings.length }))
}
const { messages, more } = this.readRoom(by, since) const { messages, more } = this.readRoom(by, since)
lines.push("") lines.push("")
if (!messages.length) lines.push(fill(R.none, { since })) if (!messages.length) lines.push(fill(R.none, { since }))
@@ -464,6 +616,7 @@ export class Team {
stopCmd(by: string, name: string): string { stopCmd(by: string, name: string): string {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no if (no) return no
if (by !== LEAD) return TEXTS.only_main
if (name === "all") { if (name === "all") {
const under = this.membersOf(by) const under = this.membersOf(by)
if (!under.length) return TEXTS.nobody_to_stop if (!under.length) return TEXTS.nobody_to_stop
@@ -487,10 +640,26 @@ export class Team {
return gone.map((a) => a.name) return gone.map((a) => a.name)
} }
private remove(gone: TeamAgent[]) { private remove(gone: TeamAgent[], end: EndState = "stopped") {
for (const a of gone) { for (const a of gone) {
this.agents.delete(a.name) this.agents.delete(a.name)
this.o.effects?.stop(a.name) a.state = end
a.endState = end
a.endedAt = this.now()
a.inbox = []
a.brief = undefined
this.ended.push(a)
for (const r of this.requests) if (r.from === a.name) r.open = false
if (end === "done") this.o.effects?.end?.(a.name)
else if (end === "stopped") this.o.effects?.stop(a.name)
// Spec 3.4.0: the subagents under one that ended by itself move under its parent until the
// main agent replaces it (team_replace moves them back under the replacement).
if (end !== "stopped")
for (const c of this.agents.values())
if (c.manager === a.name) {
c.manager = a.manager ?? LEAD
c.movedFrom = a.name
}
} }
this.fillSlots() this.fillSlots()
} }
@@ -499,7 +668,16 @@ export class Team {
* stop, steps, budget, or `error — <text>`. */ * stop, steps, budget, or `error — <text>`. */
finish(name: string, reply = "", reason = "stop") { finish(name: string, reply = "", reason = "stop") {
const a = this.agents.get(name) const a = this.agents.get(name)
if (!a || a.state !== "working") return if (!a) return
// Spec 3.4.0 §9: the final turn after its context filled has ended — it is out of context.
if (a.state === "paused") {
const gone = fill(TEXTS.gone, { name: a.name, id: a.id })
const text = reply.trim() ? `${gone}\n\n${reply.trim()}` : gone
if (a.manager) this.post(name, [a.manager], [a.manager], text, true)
this.remove([a], "out_of_context")
return
}
if (a.state !== "working") return
a.state = name === LEAD ? "idle" : "finished" a.state = name === LEAD ? "idle" : "finished"
a.ended = this.now() a.ended = this.now()
if (a.manager) { if (a.manager) {
@@ -534,6 +712,241 @@ export class Team {
this.stoppedByPerson = [] this.stoppedByPerson = []
} }
// ── spec 3.4.0: the main agent's tree (subagents.md §5–§13) ─────────────────────
/** A subagent by name (live first, else the latest that ended) or by id. */
find(nameOrId: string): TeamAgent | undefined {
return this.agents.get(nameOrId) ?? [...this.agents.values()].find((a) => a.id === nameOrId) ?? [...this.ended].reverse().find((a) => a.name === nameOrId || a.id === nameOrId)
}
/** Open slots (§6): min − 1 places for subagents not added yet, while min ≥ 2 and offered. */
openSlots(): number {
if (!this.offered || this.min < 2) return 0
return Math.max(0, this.min - 1 - (this.agents.size - 1))
}
/** The main agent's mode changed: stricter reaches down the tree, looser changes nobody (§7). */
setMainMode(mode: Mode) {
this.agents.get(LEAD)!.mode = mode
for (const a of this.tree()) if (a.manager) a.mode = strictest(a.mode, this.agents.get(a.manager)?.mode ?? mode)
}
/** Its engine's context use, and its progress, as last said. */
setContext(name: string, used: number, window: number) {
const a = this.agents.get(name)
if (a) a.context = { used, window }
}
setProgress(name: string, p: Progress | null) {
const a = this.agents.get(name)
if (a) a.progress = p
}
/** team_whoami (§8). */
whoami(by: string): string {
const a = this.agents.get(by)
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
const W = TEAM.whoami.lines
const steps = by === LEAD ? (this.o.steps?.main ?? LOOP.steps.default) : (this.o.steps?.sub ?? LOOP.jobs.subagent_steps.default)
const lines = [fill(W.who, { name: a.name, id: a.id, role: a.role })]
if (a.manager) lines.push(fill(W.parent, { parent: a.manager }))
if (a.manager) lines.push(fill(W.task, { task: a.task.split("\n")[0]! }))
lines.push(fill(W.mode, { mode: a.mode }))
if (a.request.length) lines.push(fill(W.request, { kinds: a.request.join(", ") }))
lines.push(fill(W.model, { model: a.model }))
lines.push(fill(W.limits, { size: this.size(), max: this.max, steps, spawn: by === LEAD ? W.spawn_yes : W.spawn_no }))
if (a.context && a.context.window > 0) lines.push(fill(W.context, { used: a.context.used, window: a.context.window, percent: Math.floor((a.context.used * 100) / a.context.window) }))
return lines.join("\n")
}
/** team_findings write (§8). */
noteFinding(by: string, text: string): string {
const a = this.agents.get(by)
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
const n = a.findings.length + 1
a.findings.push({ n, text: text.trim(), time: this.now() })
return fill(TEXTS.noted, { n })
}
/** Whether `by` may read `target`'s findings: its own, one in its subtree, or the lead anyone's. */
private mayRead(by: string, target: TeamAgent): boolean {
if (by === LEAD || target.name === by) return true
let up: string | null | undefined = target.endState ? (target.movedFrom ?? target.manager) : target.manager
for (let guard = 0; up && guard < 1000; guard++) {
if (up === by) return true
up = this.agents.get(up)?.manager ?? this.find(up)?.manager
}
return false
}
/** team_findings read (§8). */
readFindings(by: string, of?: string): string {
const target = of ? this.find(of) : this.agents.get(by)
if (!target) return fill(TEXTS.unknown, { name: of ?? by, names: this.names() })
if (!this.mayRead(by, target)) return TEXTS.not_yours_findings
const lines = [fill(TEAM.findings.header, { name: target.name, id: target.id })]
if (!target.findings.length) lines.push(TEXTS.no_findings)
for (const f of target.findings) lines.push(fill(TEAM.findings.item, { n: f.n, text: f.text }))
return lines.join("\n")
}
/** team_finish (§8): the report to its parent, and it ends (done). */
finishTask(by: string, report: string): string {
const a = this.agents.get(by)
if (!a || !a.manager) return TEXTS.no_parent
this.post(by, [a.manager], [a.manager], report.trim() || TEXTS.no_report, true)
const to = a.manager
this.remove([a], "done")
return fill(TEXTS.finished, { to })
}
/** Its context is full (§9): paused, and given one final turn. The text of that turn. */
pause(name: string): string | undefined {
const a = this.agents.get(name)
if (!a || a.name === LEAD || a.state === "paused") return undefined
a.state = "paused"
a.inbox = []
const text = `${fill(TEAM.marks.context_full, { to: name })}\n${fill(TEXTS.context_full, { steps: TEAM.context.final_steps })}`
this.delivered.push({ name, text })
this.o.effects?.final?.(name, text)
return text
}
/** team_assign (§8). */
assign(by: string, a: { name: string; task: string; role?: string; parent?: string; mode?: Mode }): string {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no
if (by !== LEAD) return TEXTS.only_main
const t = this.agents.get(a.name)
if (!t || t.name === LEAD) return this.find(a.name)?.endState ? fill(TEXTS.has_ended, { name: a.name }) : fill(TEXTS.unknown, { name: a.name, names: this.names() })
const parentName = a.parent ?? t.manager!
const p = this.agents.get(parentName)
if (!p) return fill(TEXTS.unknown, { name: parentName, names: this.names() })
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
if (this.subtree(t.name).some((x) => x.name === p.name)) return fill(TEXTS.cycle, { name: t.name, parent: p.name })
let mode = strictest(t.mode, p.mode)
let request = t.request
if (a.mode) {
const m = checkMode(p.mode, a.mode, t.request.length && a.mode !== p.mode ? t.request : undefined)
if ("error" in m) return m.error
mode = m.mode
request = m.mode === p.mode ? [] : t.request
}
t.task = a.task
if (a.role?.trim()) t.role = a.role.trim().split("\n")[0]!
t.manager = p.name
t.movedFrom = undefined
t.mode = mode
t.request = request
for (const c of this.subtree(t.name).slice(1)) c.mode = strictest(c.mode, this.agents.get(c.manager!)!.mode)
this.post(LEAD, [t.name], [t.name], a.task, false, "assignment")
this.fillSlots()
return fill(TEXTS.assigned, { name: t.name })
}
/** team_replace (§8): the same name, position, parent, mode and model; a new id; briefed with
* the task and the old one's findings. */
replace(by: string, a: { name: string; task?: string }): { text: string; added?: TeamAgent } {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return { text: no }
if (by !== LEAD) return { text: TEXTS.only_main }
const old = this.agents.get(a.name) ?? [...this.ended].reverse().find((x) => x.name === a.name)
if (!old || old.name === LEAD) return { text: fill(TEXTS.unknown, { name: a.name, names: this.names() }) }
const live = this.agents.get(a.name) === old
if (!live && this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
if (live) {
// It alone ends: the subagents under it move to the replacement.
for (const c of this.membersOf(old.name)) c.movedFrom = old.name
for (const c of this.membersOf(old.name)) c.manager = old.manager ?? LEAD
this.remove([old], "stopped")
}
const manager = old.movedFrom ?? old.manager
const parent = manager && this.agents.get(manager) ? manager : LEAD
const findings = old.findings.length ? old.findings.map((f) => fill(TEAM.findings.item, { n: f.n, text: f.text })).join("\n") : TEXTS.no_findings
const brief = fill(TEXTS.replace_brief, { task: a.task?.trim() || old.task, name: old.name, id: old.id, findings })
const p = this.agents.get(parent)!
const fresh: TeamAgent = {
...old,
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
task: a.task?.trim() || old.task,
mode: strictest(old.mode, p.mode),
grants: [],
findings: [],
context: undefined,
progress: undefined,
manager: parent,
state: "finished",
inbox: [],
brief,
seq: this.seq++,
endState: undefined,
endedAt: undefined,
movedFrom: undefined,
ended: undefined,
waitSince: undefined,
jobId: undefined,
}
this.agents.set(fresh.name, fresh)
for (const c of this.agents.values())
if (c.movedFrom === old.name) {
c.manager = fresh.name
c.movedFrom = undefined
}
this.want(fresh)
this.fillSlots()
return { text: fill(TEXTS.replaced, { name: fresh.name, id: fresh.id, size: this.size(), max: this.max }), added: fresh }
}
/** team_request (§7): `cls` is the tool's risk class. */
request(by: string, a: { tool: string; pattern: string; why: string }, cls: ToolClass | undefined): string {
const t = this.agents.get(by)
if (!t || !t.manager) return TEXTS.no_parent
if (!cls || !t.request.includes(cls)) return fill(TEXTS.not_requestable, { tool: a.tool, kinds: t.request.join(", ") || "none" })
const id = `${TEAM.request.id_prefix}${++this.reqSeq}`
this.requests.push({ id, from: by, tool: a.tool, pattern: a.pattern, why: a.why, open: true })
this.post(by, [LEAD], [LEAD], fill(TEXTS.request_body, { tool: a.tool, pattern: a.pattern, why: a.why }), false, "request", id)
this.fillSlots()
return fill(TEXTS.requested, { id })
}
/** team_decide (§7): a grant for the subagent when allowed; it is told either way. `permission`:
* the tool's permission key. */
decide(by: string, a: { id: string; allow: boolean; note?: string }, permission?: (tool: string) => string | undefined): string {
if (by !== LEAD) return TEXTS.only_main
const r = this.requests.find((x) => x.id === a.id && x.open)
const t = r && this.agents.get(r.from)
if (!r || !t) return fill(TEXTS.no_request, { id: a.id })
r.open = false
if (a.allow) t.grants.push({ tool: r.tool, permission: permission?.(r.tool) ?? r.tool, pattern: r.pattern })
const head = fill(a.allow ? TEXTS.decision_allowed : TEXTS.decision_refused, { tool: r.tool, pattern: r.pattern })
this.post(LEAD, [t.name], [t.name], a.note?.trim() ? `${head}\n\n${a.note.trim()}` : head, false, "decision", r.id)
this.fillSlots()
return fill(TEXTS.decided, { decision: a.allow ? TEXTS.allowed : TEXTS.refused, id: r.id, name: t.name })
}
/** The tree as clients show it (§12). */
info(): TreeInfo {
const row = (a: TeamAgent): TreeAgent => ({
id: a.id,
name: a.name,
position: a.role,
parent: a.manager,
state: a.state,
model: a.model,
mode: a.mode,
request: [...a.request],
manager: a.name === LEAD ? true : a.leads,
...(a.manager ? { task: a.task } : {}),
...(a.context ? { context: { ...a.context } } : {}),
progress: a.progress ?? null,
findings: a.findings.length,
messages: this.room.filter((m) => m.from === a.name || m.recipients.includes(a.name)).length,
...(a.endedAt !== undefined ? { ended: a.endedAt } : {}),
})
return { min: this.min, max: this.max, size: this.size(), open_slots: this.openSlots(), agents: this.tree().map(row), ended: this.ended.map(row) }
}
// ── what the lead is told ─────────────────────────────────────────────────── // ── what the lead is told ───────────────────────────────────────────────────
/** §10: the note's entries for the lead's next user message — the size when it was not said yet /** §10: the note's entries for the lead's next user message — the size when it was not said yet
@@ -545,7 +958,7 @@ export class Team {
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) { if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
this.told = { min: this.min, max: this.max } this.told = { min: this.min, max: this.max }
if (this.max === 1) out.push(N.team_one) if (this.max === 1) out.push(N.team_one)
else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : [])].join("\n")) else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : []), ...(this.couldServe().own ? [] : [N.team_own])].join("\n"))
} }
if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") })) if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") }))
return out return out
+9 -1
View File
@@ -11,7 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
import { planUpdateTool } from "./plan_update.ts" import { planUpdateTool } from "./plan_update.ts"
import { planWriteTool } from "./plan_write.ts" import { planWriteTool } from "./plan_write.ts"
import { taskTool } from "./task.ts" import { taskTool } from "./task.ts"
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts" import { teamAddTool, teamAssignTool, teamDecideTool, teamFindingsTool, teamFinishTool, teamReadTool, teamReplaceTool, teamRequestTool, teamSendTool, teamStopTool, teamWhoamiTool } from "./team_tools.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts" import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts" import { webFetchTool, webSearchTool } from "./web.ts"
import { memoryTool } from "./memory.ts" import { memoryTool } from "./memory.ts"
@@ -46,6 +46,14 @@ export const BUILTIN_TOOLS: Tool[] = [
teamSendTool, teamSendTool,
teamReadTool, teamReadTool,
teamStopTool, teamStopTool,
// Spec 3.4.0 (subagents.md §8).
teamWhoamiTool,
teamFindingsTool,
teamFinishTool,
teamRequestTool,
teamDecideTool,
teamAssignTool,
teamReplaceTool,
planWriteTool, planWriteTool,
planUpdateTool, planUpdateTool,
tasksTool, tasksTool,
+116 -2
View File
@@ -1,5 +1,6 @@
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the // The team tools (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13; tools/team_*.json):
// room, read the team and the room, stop a member. The rules are team/team.ts; these are its calls // add a subagent, send through the room, read the tree and the room, stop one — and from 3.4.0
// whoami, findings, finish, request and decide, assign and replace. The rules are team/team.ts; these are its calls
// for the agent the context names (`ctx.team.as`). // for the agent the context names (`ctx.team.as`).
import { z } from "zod" import { z } from "zod"
import { defineTool, ToolError, type ToolContext } from "./tool.ts" import { defineTool, ToolError, type ToolContext } from "./tool.ts"
@@ -37,6 +38,9 @@ export const teamAddTool = defineTool({
lead: z.boolean().optional(), lead: z.boolean().optional(),
agent: z.string().optional(), agent: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
parent: z.string().optional(),
mode: z.enum(["auto", "edit", "manual", "plan"]).optional(),
request: z.array(z.enum(["read", "write", "execute"])).optional(),
}), }),
invalid: badName, invalid: badName,
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }), permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
@@ -92,3 +96,113 @@ export const teamStopTool = defineTool({
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size } return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
}, },
}) })
export const teamWhoamiTool = defineTool({
...described("team_whoami"),
purpose: false,
access: "team_whoami",
requires: "team",
schema: z.object({}),
permission: () => ({ permission: "team_whoami", class: "read", patterns: ["*"] }),
async run(_a, ctx) {
const t = teamOf(ctx)
return { output: t.runtime.team.whoami(t.as), title: t.as }
},
})
export const teamFindingsTool = defineTool({
...described("team_findings"),
purpose: false,
access: "team_findings",
requires: "team",
schema: z.object({ action: z.enum(["write", "read"]), text: z.string().optional(), of: z.string().optional() }),
permission: (a) => ({ permission: "team_findings", class: "interact", patterns: [a.action] }),
async run(a, ctx) {
const t = teamOf(ctx)
if (a.action === "write") {
if (!a.text?.trim()) throw new ToolError("write needs `text`: the finding.")
const output = t.runtime.team.noteFinding(t.as, a.text)
t.runtime.notify()
return { output, title: "finding noted" }
}
return { output: t.runtime.team.readFindings(t.as, a.of), title: `findings of ${a.of ?? t.as}` }
},
})
export const teamFinishTool = defineTool({
...described("team_finish"),
purpose: false,
access: "team_finish",
requires: "team",
schema: z.object({ report: z.string().min(1) }),
permission: () => ({ permission: "team_finish", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const output = t.runtime.team.finishTask(t.as, a.report)
t.runtime.notify()
return { output, title: "finished" }
},
})
export const teamRequestTool = defineTool({
...described("team_request"),
purpose: false,
access: "team_request",
requires: "team",
schema: z.object({ tool: z.string().min(1), pattern: z.string().min(1), why: z.string().min(1) }),
permission: () => ({ permission: "team_request", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const before = t.runtime.team.requests.length
const output = t.runtime.team.request(t.as, a, t.runtime.classOf(a.tool))
t.runtime.notify()
const asked = t.runtime.team.requests.length > before
return { output, title: asked ? `asked for ${a.tool} ${a.pattern}` : "not asked", isError: !asked }
},
})
export const teamDecideTool = defineTool({
...described("team_decide"),
purpose: false,
access: "team_decide",
requires: "team",
schema: z.object({ id: z.string().min(1), allow: z.boolean(), note: z.string().optional() }),
permission: () => ({ permission: "team_decide", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const output = t.runtime.team.decide(t.as, a, (tool) => t.runtime.permissionKeyOf(tool))
t.runtime.notify()
return { output, title: `${a.allow ? "allowed" : "refused"} ${a.id}` }
},
})
export const teamAssignTool = defineTool({
...described("team_assign"),
purpose: false,
access: "team_assign",
requires: "team",
schema: z.object({ name: z.string().min(1), task: z.string().min(1), role: z.string().optional(), parent: z.string().optional(), mode: z.enum(["auto", "edit", "manual", "plan"]).optional() }),
permission: (a) => ({ permission: "team_assign", class: "interact", patterns: [a.name] }),
async run(a, ctx) {
const t = teamOf(ctx)
const output = t.runtime.team.assign(t.as, a)
t.runtime.syncModes()
t.runtime.notify()
return { output, title: `to ${a.name}` }
},
})
export const teamReplaceTool = defineTool({
...described("team_replace"),
purpose: false,
access: "team_replace",
requires: "team",
schema: z.object({ name: z.string().min(1), task: z.string().optional() }),
permission: (a) => ({ permission: "team_replace", class: "interact", patterns: [a.name] }),
async run(a, ctx) {
const t = teamOf(ctx)
const r = t.runtime.team.replace(t.as, a)
t.runtime.notify()
return { output: r.text, title: r.added ? `${a.name} → ${r.added.id}` : "not replaced", isError: !r.added }
},
})
+2
View File
@@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const sendToJob = (text: string) => { const sendToJob = (text: string) => {
const id = entered()! const id = entered()!
const job = app.registry.get(app.jobsSessionId(), id) const job = app.registry.get(app.jobsSessionId(), id)
// Spec 3.4.0 §11: a subagent of the tree is read-only; the person speaks through the main agent.
if (job?.team) return view.info(`${id} is ${job.team}, a subagent of the main agent: its session is read-only — tell the main agent, or /team stop ${job.team}`, "warn")
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn") if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
if (job.state === "running") { if (job.state === "running") {
job.subagent.send(text) job.subagent.send(text)
+78 -23
View File
@@ -11,11 +11,13 @@ import { doRelease, planRelease } from "../git/release.ts"
import { instructionFiles } from "../prompt/assemble.ts" import { instructionFiles } from "../prompt/assemble.ts"
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
import { contextBreakdown, renderBreakdown } from "../session/context.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts"
import { SUBAGENTS } from "../team/subagents.ts"
import { TEAM } from "../team/team.ts"
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts" import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
import type { Ctx } from "./context.ts" import type { Ctx } from "./context.ts"
import type { DialogSpec } from "./components/dialog.tsx" import type { DialogSpec, Option } from "./components/dialog.tsx"
import { deleteEverywhere } from "../acp/share.ts" import { deleteEverywhere } from "../acp/share.ts"
import { themeNames } from "./theme.ts" import { themeNames } from "./theme.ts"
import { render } from "../plan/plan.ts" import { render } from "../plan/plan.ts"
@@ -85,7 +87,6 @@ const pendingCodes = new Map<string, (text: string) => void>()
const AGENT_TEMPLATE = (name: string) => `--- const AGENT_TEMPLATE = (name: string) => `---
description: what ${name} is for — the model reads this when it picks a subagent description: what ${name} is for — the model reads this when it picks a subagent
# model: provider/model # run it on another of the server's models (default: the session's)
# mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours # mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours
# tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have # tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have
# isolation: worktree # work in a git checkout and branch of its own # isolation: worktree # work in a git checkout and branch of its own
@@ -96,23 +97,44 @@ report is all the session's model sees of your work.
const TEAM_ROW = "\u0000team" const TEAM_ROW = "\u0000team"
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */ /** The Subagents setting as one range from 1 to the cap with two handles, Min and Max (harness
* team.md §2): the numbers count this agent, so 1 is this agent alone. */
function teamRange(c: CommandCtx): Option {
const t = c.app.team.team
const S = SUBAGENTS.setting
return {
label: S.label,
value: TEAM_ROW,
// The session's model cannot serve subagents itself: said where the size is set (subagents.md §4).
description: t.couldServe().own ? S.short : S.own,
range: { from: 1, to: t.cap, lo: t.min, hi: t.max, names: [S.min, S.max], onSubmit: (lo, hi) => applyTeamSize(c, `${lo} ${hi}`) },
}
}
const applyTeamSize = (c: CommandCtx, v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
/** The team's size for this session: the range, a few usual pairs, or any typed as `<min> <max>`. */
function openTeamSize(c: CommandCtx) { function openTeamSize(c: CommandCtx) {
const t = c.app.team.team const t = c.app.team.team
const pairs = [ const pairs = [
[1, 1, "one agent: no team, no helper"], [1, 1, "this agent alone: no subagents"],
[1, 4, "you decide; up to three members"], [1, 4, "this agent decides; up to three subagents"],
[2, 4, "a team from the start"], [2, 4, "subagents from the start"],
[1, 8, "you decide; up to seven members"], [1, 8, "this agent decides; up to seven subagents"],
[3, 8, "a team of at least three"], [3, 8, "at least two subagents"],
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"], [1, Math.min(16, t.cap), "many subagents, with sub-managers"],
] as const ] as const
const S = SUBAGENTS.setting
const seen = new Set<string>() const seen = new Set<string>()
const options = pairs const options = pairs
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`)) .filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max })) .map(([min, max, what]) => ({ label: `${S.min} ${min}, ${S.max} ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh()) c.openDialog({
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } }) title: `${S.label} — ${S.min} ${t.min}, ${S.max} ${t.max} (1 to ${t.cap}, this agent included)`,
options: [teamRange(c), ...options],
onSelect: (v) => applyTeamSize(c, v),
custom: { label: "set to <min> <max>", onSubmit: (v) => applyTeamSize(c, v) },
})
} }
export const COMMANDS: Command[] = [ export const COMMANDS: Command[] = [
@@ -146,7 +168,7 @@ export const COMMANDS: Command[] = [
const describe = (a: (typeof list)[number]) => const describe = (a: (typeof list)[number]) =>
[ [
`${a.name} (${a.source}) — ${a.description}`, `${a.name} (${a.source}) — ${a.description}`,
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, ` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
].join("\n") ].join("\n")
if (named) return c.view.info(describe(named)) if (named) return c.view.info(describe(named))
c.view.info( c.view.info(
@@ -215,10 +237,10 @@ export const COMMANDS: Command[] = [
c.setEffort(v as Level) c.setEffort(v as Level)
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`) c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
} }
// The team's size sits beside the effort (team.md §2: shown only where a team is offered). // The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where
const team = c.app.team.team // subagents are offered).
const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : [] const agentsRow = c.app.team.team.offered ? [teamRange(c)] : []
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v)) const choose = (v: string) => set(v)
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`) if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
if (arg) return set(arg) if (arg) return set(arg)
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose }) if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
@@ -241,12 +263,29 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "team", name: "team",
args: "[size [<min> <max>] | stop [<name>]]", args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
description: "the team: its agents and the room; its size; stop a member", description: "subagents: the tree and the room; how many; stop one; their findings",
run(c, arg) { run(c, arg) {
// The tree and the room as the lead sees them — the person sees everything (team.md §5). // The tree and the room as the lead sees them — the person sees everything (team.md §5).
const words = arg.trim().split(/\s+/).filter(Boolean) const words = arg.trim().split(/\s+/).filter(Boolean)
if (words[0] === "size" && words.length === 1) return openTeamSize(c) if (words[0] === "size" && words.length === 1) return openTeamSize(c)
// Spec 3.4.0 §11: a subagent's session, read-only, live or after it ended.
if (words[0] === "open") {
const t = c.app.team.team
const all = [...t.tree().filter((a) => a.manager !== null), ...t.ended]
const open = (key: string) => {
const a = t.find(key)
if (!a?.jobId) return c.view.info(`no subagent ${key} with a session here — /team lists them`, "warn")
c.enterJob(a.jobId)
}
if (words[1]) return open(words[1])
if (!all.length) return c.view.info("no subagents yet")
return c.openDialog({
title: "A subagent's session — read-only",
options: all.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, description: `${a.role} · ${TEAM.read.states[a.state]}` })),
onSelect: open,
})
}
if (words[0] === "stop" && words.length === 1) { if (words[0] === "stop" && words.length === 1) {
const members = c.app.team.team.tree().filter((a) => a.manager !== null) const members = c.app.team.team.tree().filter((a) => a.manager !== null)
if (!members.length) return c.view.info("the team has no members") if (!members.length) return c.view.info("the team has no members")
@@ -687,8 +726,24 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "context", name: "context",
description: "where the context window goes", args: "[<subagent>]",
run(c) { description: "where the context window goes — the session's, or a subagent's",
run(c, arg) {
// Spec 3.4.0 §12: a subagent's context, by its name or id.
const who = arg.trim()
if (who) {
const t = c.app.team.team
const a = t.find(who)
if (!a) return c.view.info(`no subagent ${who} — /team lists them`, "warn")
const h = c.app.team.handle(a.name)
if (!h || a.endState) {
const u = a.context
return c.view.info(u ? `${a.name} (${a.id}): ${u.used} of ${u.window} tokens (${Math.floor((u.used * 100) / u.window)}%) when it ${a.endState ? TEAM.read.states[a.state] : "last said"}` : `${a.name} (${a.id}): no context use known yet`)
}
const se = h.engine
const sys = se.systemPrompt()
return c.view.info(`${a.name} (${a.id}) — ${a.role}\n${renderBreakdown(contextBreakdown(se, sys, [], undefined, se.o.tools))}`)
}
const e = c.app.engine const e = c.app.engine
// The prompt the session sends (made now if no request has gone yet). // The prompt the session sends (made now if no request has gone yet).
const system = e.systemPrompt() const system = e.systemPrompt()
@@ -1006,7 +1061,7 @@ export const COMMANDS: Command[] = [
? [...(e.def.values ?? [])] ? [...(e.def.values ?? [])]
: [] : []
c.openDialog({ c.openDialog({
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}`, title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })), options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
onSelect: (v) => askScope(key, v), onSelect: (v) => askScope(key, v),
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) }, custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
@@ -1022,7 +1077,7 @@ export const COMMANDS: Command[] = [
options: S.list().map((e) => ({ options: S.list().map((e) => ({
label: e.def.key, label: e.def.key,
value: e.def.key, value: e.def.key,
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}`, description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
})), })),
onSelect: askValue, onSelect: askValue,
}) })
+68 -1
View File
@@ -8,6 +8,31 @@ export interface Option {
value: string value: string
description?: string description?: string
current?: boolean current?: boolean
/** A two-handle range on this row (the Subagents setting, harness team.md §2: one range from 1
* to the cap, Min and Max). While the row is highlighted ←→ move the handle in focus and tab
* moves the focus; Min never passes Max. Enter calls `onSubmit` with both, not `onSelect`. */
range?: Range
}
export interface Range {
/** The bounds of the track. */
from: number
to: number
/** The handles' values. */
lo: number
hi: number
/** The handles' names (Min, Max). */
names: [string, string]
onSubmit: (lo: number, hi: number) => void
}
/** The track as text: `─` outside the range, `━` inside it, `●` for a handle. */
export function rangeTrack(r: Pick<Range, "from" | "to">, lo: number, hi: number, width = 16): { before: string; lo: string; between: string; hi: string; after: string } {
const span = Math.max(1, r.to - r.from)
const at = (v: number) => Math.round(((v - r.from) / span) * (width - 1))
const a = at(lo)
const b = Math.max(at(hi), a + (hi > lo ? 1 : 0))
return { before: "─".repeat(a), lo: "●", between: "━".repeat(Math.max(0, b - a - 1)), hi: b > a ? "●" : "", after: "─".repeat(Math.max(0, width - 1 - b)) }
} }
export interface DialogSpec { export interface DialogSpec {
@@ -45,6 +70,16 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
}) })
// A range row's handles, moved while the dialog is open and handed over on enter; esc drops them.
const [ranges, setRanges] = createSignal<Record<string, [number, number]>>({})
const [focus, setFocus] = createSignal<0 | 1>(1)
const valuesOf = (o: Option): [number, number] => ranges()[o.value] ?? [o.range!.lo, o.range!.hi]
const move = (o: Option, by: number) => {
const r = o.range!
const [lo, hi] = valuesOf(o)
const next: [number, number] = focus() === 0 ? [Math.min(hi, Math.max(r.from, lo + by)), hi] : [lo, Math.min(r.to, Math.max(lo, hi + by))]
setRanges((m) => ({ ...m, [o.value]: next }))
}
const start = Math.max(0, p.spec.options.findIndex((o) => o.current)) const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
const [sel, setSel] = createSignal(start) const [sel, setSel] = createSignal(start)
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row. // The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
@@ -70,6 +105,8 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
run(o.value) run(o.value)
} }
} else if (key.name === "escape") p.close() } else if (key.name === "escape") p.close()
else if (shown()[sel()]?.range && (key.name === "left" || key.name === "right")) move(shown()[sel()]!, key.name === "left" ? -1 : 1)
else if (shown()[sel()]?.range && key.name === "tab") setFocus((f) => (f === 0 ? 1 : 0))
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1)) else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1)) else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
else if (key.name === "return") { else if (key.name === "return") {
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const onSelect = p.spec.onSelect const onSelect = p.spec.onSelect
const custom = p.spec.custom const custom = p.spec.custom
const typed = query().trim() const typed = query().trim()
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
p.close() p.close()
if (o.value === CUSTOM) custom?.onSubmit(typed) if (o.value === CUSTOM) custom?.onSubmit(typed)
else if (range) range.submit(...range.values)
else onSelect(o.value) else onSelect(o.value)
} }
} else if (filterable() && key.name === "backspace") { } else if (filterable() && key.name === "backspace") {
@@ -104,6 +143,30 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<For each={shown().slice(offset(), offset() + visible())}> <For each={shown().slice(offset(), offset() + visible())}>
{(o, i) => { {(o, i) => {
const active = () => i() + offset() === sel() const active = () => i() + offset() === sel()
if (o.range) {
const values = () => valuesOf(o)
const track = () => rangeTrack(o.range!, values()[0], values()[1])
const handle = (n: 0 | 1) => (active() && focus() === n ? c().accent : active() ? c().title : c().text)
return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
{" "}
{o.label}
{" "}
<span style={{ fg: handle(0) }}>{`${o.range.names[0]} ${values()[0]}`}</span>
{" "}
<span style={{ fg: c().dim }}>{track().before}</span>
<span style={{ fg: handle(0) }}>{track().lo}</span>
<span style={{ fg: c().accent }}>{track().between}</span>
<span style={{ fg: handle(1) }}>{track().hi}</span>
<span style={{ fg: c().dim }}>{track().after}</span>
{" "}
<span style={{ fg: handle(1) }}>{`${o.range.names[1]} ${values()[1]}`}</span>
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
</text>
</box>
)
}
return ( return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}> <box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}> <text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
@@ -118,7 +181,11 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<Show when={shown().length === 0}> <Show when={shown().length === 0}>
<text fg={c().muted}>nothing matches</text> <text fg={c().muted}>nothing matches</text>
</Show> </Show>
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text> <text fg={c().dim}>
{shown()[sel()]?.range
? `←→ move ${shown()[sel()]!.range!.names[focus()]} · tab ${shown()[sel()]!.range!.names[focus() === 0 ? 1 : 0]} · enter set · esc close`
: (p.spec.hint ?? "↑↓ move · enter choose · esc close")}
</text>
</box> </box>
</box> </box>
) )
+19 -2
View File
@@ -1,11 +1,18 @@
import { createSignal, For, onCleanup } from "solid-js" import { createSignal, For, onCleanup } from "solid-js"
import { duration } from "../../duration.ts" import { duration } from "../../duration.ts"
import { progressLine } from "../../session/progress.ts"
import { useTui } from "../context.ts" import { useTui } from "../context.ts"
import { fmtTokens } from "./status.tsx" import { fmtTokens } from "./status.tsx"
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's /** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
* return_progress). The time left is guessed from the reading so far, once there is enough of it; * return_progress). The time left is guessed from the reading so far, once there is enough of it;
* a long prompt reads slower towards its end, so it is a floor more than a promise. */ * a long prompt reads slower towards its end, so it is a floor more than a promise. */
/** How long the rest of the prompt will take, from the server's pace so far; "" until it is known. */
export function etaOf(p: { total: number; done: number; processed: number; ms: number }): string {
if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) return `~${duration(((p.total - p.done) / p.processed) * p.ms, true)} left`
return ""
}
export function reading(p: { total: number; done: number; processed: number; ms: number }): string { export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
const pct = Math.floor((p.done / p.total) * 100) const pct = Math.floor((p.done / p.total) * 100)
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`] const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
@@ -34,6 +41,16 @@ export function Spinner() {
} }
const what = () => { const what = () => {
const s = view.state const s = view.state
// Spec 3.4.0: the phase the engine says, drawn as every client draws it (progress.md §3).
if (s.now) {
tick()
// The CLI's own look around the shared line: how long the rest of the prompt will take.
// The server's counts as they come (the phase event is paced for the link; the screen is not).
const reading = s.now.phase === "reading_prompt" && s.progress
const now = reading ? { ...s.now, done: s.progress!.done, total: s.progress!.total } : s.now
const eta = reading ? etaOf(s.progress!) : ""
return `${progressLine(now)}${eta ? ` · ${eta}` : ""}`
}
if (s.preparing) return `preparing ${s.preparing}` if (s.preparing) return `preparing ${s.preparing}`
if (s.phase === "waiting" && s.progress) return reading(s.progress) if (s.phase === "waiting" && s.progress) return reading(s.progress)
if (s.phase === "thinking") return pick(sp().verbs, 30) if (s.phase === "thinking") return pick(sp().verbs, 30)
@@ -45,9 +62,9 @@ export function Spinner() {
<box paddingLeft={1} height={1}> <box paddingLeft={1} height={1}>
<text wrapMode="none" truncate> <text wrapMode="none" truncate>
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span> <span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
<span style={{ fg: theme().colors.muted }}>{what()}… </span> <span style={{ fg: theme().colors.muted }}>{what()}{view.state.now ? " · " : "… "}</span>
<span style={{ fg: theme().colors.dim }}> <span style={{ fg: theme().colors.dim }}>
{elapsed()} · {tokens()} · esc to stop {view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
</span> </span>
</text> </text>
</box> </box>
+1 -1
View File
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
const j = job() const j = job()
if (!j) return `subagent · ${p.id} · gone · esc back` if (!j) return `subagent · ${p.id} · gone · esc back`
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j) const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)" const message = j.team ? `read-only: ${j.team} works for the main agent` : j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ") return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
} }
const what = () => job()?.description ?? "" const what = () => job()?.description ?? ""
+6 -1
View File
@@ -82,7 +82,7 @@ export interface Pending {
/** What the model says the call is for. */ /** What the model says the call is for. */
purpose?: string purpose?: string
/** The subagent (running as a job) that asks, when one does. */ /** The subagent (running as a job) that asks, when one does. */
source?: { jobId: string; agent: string; description: string } source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
resolve: (r: AskReply) => void resolve: (r: AskReply) => void
} }
@@ -120,6 +120,8 @@ export interface ViewState {
progress?: { total: number; done: number; processed: number; ms: number } progress?: { total: number; done: number; processed: number; ms: number }
/** The session's background jobs (the registry's list, kept from `job` events), running first. */ /** The session's background jobs (the registry's list, kept from `job` events), running first. */
jobs: TrackedJob[] jobs: TrackedJob[]
/** Spec 3.4.0 (harness/progress.md): what the agent is doing now — the spinner line. */
now?: import("../session/progress.ts").Progress | null
} }
// What the model was given beside the words typed — attachments, and a session note // What the model was given beside the words typed — attachments, and a session note
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
if (e.state === "start") startTask() if (e.state === "start") startTask()
else inTask = false else inTask = false
break break
case "phase":
set("now", e.progress ? { ...e.progress } : null)
break
case "sub_tool": case "sub_tool":
set( set(
"items", "items",
+1 -1
View File
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
], { route: (model) => (model.startsWith("g/") ? child : undefined) }) ], { route: (model) => (model.startsWith("g/") ? child : undefined) })
config(fake.url, true) config(fake.url, true)
const { c, events, sid } = await client() const { c, events, sid } = await client()
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] }) await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go (a subagent on g/m)" }] })
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true) expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
const listed: any = await c.request("_lembas/jobs", { sessionId: sid }) const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" }) expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
+1 -1
View File
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
config(fake.url) config(fake.url)
const root = project() const root = project()
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }) const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show"]) expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show", "progress", "tree", "agents"])
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off") await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true }) const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
+30 -3
View File
@@ -51,10 +51,37 @@ test("task on a model the server cannot hold beside the session's is refused, an
) )
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n") writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } }) const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("get a second opinion") await a.engine.prompt("get a second opinion from f/other")
// Two requests: the session's, then its next step — never one for f/other. // Two requests: the session's, then its next step — never one for f/other.
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"]) expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
const result = fake.requests[1].messages.at(-1).content as string const result = fake.requests[1].messages.at(-1).content as string
expect(result).toContain("holds one model at a time") expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
expect(result).toContain("Do this part yourself") })
test("spec 3.3.0: task on another model the person did not name is refused, and nothing runs", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "second opinion", prompt: "look at it", model: "f/other" }))] },
{ chunks: [delta({ content: "I will do it myself." })] },
])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: {}, f/other: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("get a second opinion")
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
expect(fake.requests[1].messages.at(-1).content as string).toContain("Use another model for subagents only when the person names it.")
})
test("spec 3.3.0: a one-request session model is not its own subagent, and nothing else is picked", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "look", prompt: "look at it" }))] },
{ chunks: [delta({ content: "I will do it myself." })] },
])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: { parallel: 1 }, f/other: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("look into it")
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
expect(fake.requests[1].messages.at(-1).content as string).toContain("This model serves one request at a time: name another model, on another server, for subagents.")
}) })
+101 -10
View File
@@ -27,9 +27,13 @@ import { jobNote, type NoteCase } from "../src/jobs/note.ts"
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts" import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
import { envLines, promptText } from "../src/prompt/assemble.ts" import { envLines, promptText } from "../src/prompt/assemble.ts"
import { absPath, type ToolContext } from "../src/tool/tool.ts" import { absPath, type ToolContext } from "../src/tool/tool.ts"
import { TOOL_SPECS } from "../src/harness.ts" import { LOOP, TOOL_SPECS } from "../src/harness.ts"
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts" import { LEAD_TOOLS, memberTools, Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
import { noteBlock } from "../src/session/prefix.ts" import { noteBlock } from "../src/session/prefix.ts"
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
import { progressLine, progressText, type Progress } from "../src/session/progress.ts"
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
import { fillPrompt as fillText } from "../src/prompt/fill.ts"
type Case = Record<string, unknown> type Case = Record<string, unknown>
@@ -233,12 +237,35 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext) if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
throw new Error(`paths: unknown op ${String(c.op)}`) throw new Error(`paths: unknown op ${String(c.op)}`)
}, },
// Subagents (subagents.md, spec 3.3.0): the model a subagent runs on, through the rules spawn and
// team_add ask; the offer through the Team the app makes (its tools, setting and note).
subagents: (c) => {
const models = Object.entries(c.models as Record<string, Omit<ModelInfo, "ref">>).map(([ref, m]) => ({ ref, ...m }))
const byRef = (ref: string) => models.find((m) => m.ref === ref)!
const session = byRef(c.session as string)
if (c.op === "model") {
const r = subagentModel(session, c.model === undefined ? undefined : byRef(c.model as string), (c.said as string[] | undefined) ?? [])
return "refused" in r ? { refused: r.refused } : { model: r.model }
}
if (c.op === "offer") {
const subagents = c.subagents !== false
const pure = offer(session, models, subagents)
const team = new Team({ min: 1, max: 4, subagents, model: session.ref, capacity: (ref) => byRef(ref).capacity, models: () => models.map((m) => m.ref) })
if (team.offered !== pure.offered) throw new Error("subagents: the Team and the rule disagree on the offer")
const noted = team.offered && team.noteLines()[0]!.split("\n").includes(LOOP.context.note.team_own)
return { offered: team.offered, own: pure.own, note: noted ? LOOP.context.note.team_own : null }
}
throw new Error(`subagents: unknown op ${String(c.op)}`)
},
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules // Teams (team.md): one lead session's team, step by step, through the CLI's own rules
// (team/team.ts); the app drives the same class from real turns. // (team/team.ts); the app drives the same class from real turns.
team: (c) => { team: (c) => {
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity } const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity; mode?: Mode }
let capacity = start.capacity let capacity = start.capacity
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity }) const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity, mode: start.mode ?? "edit", steps: { main: 200, sub: 50 } })
// What each agent's turn was started with (a brief, or the messages that woke it).
const started: Record<string, string> = {}
team.effects = { start: (n, t) => void (started[n] = t), deliver: () => {}, stop: () => {} }
const delivered = () => { const delivered = () => {
const out: Record<string, string[]> = {} const out: Record<string, string[]> = {}
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text) for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
@@ -249,8 +276,8 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
switch (st.op) { switch (st.op) {
case "offered": { case "offered": {
const a = team.get(by)! const a = team.get(by)!
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"] const tools = !team.offered ? [] : by === "lead" ? [...LEAD_TOOLS] : memberTools(a)
return { tools: tools.sort(), setting: team.offered } return { tools: [...tools].sort(), setting: team.offered }
} }
case "call": { case "call": {
const args = st.args as Record<string, any> const args = st.args as Record<string, any>
@@ -260,20 +287,29 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") })) const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
return { room, delivered: delivered() } return { room, delivered: delivered() }
} }
if (st.tool === "team_add") result = team.add(by, args as any).text const first = (TEAM_TOOLS as readonly string[]).includes(st.tool) ? team.refusal(by, st.tool, args) : undefined
if (first) result = first
else if (st.tool === "team_add") result = team.add(by, args as any).text
else if (st.tool === "team_send") result = team.send(by, args as any) else if (st.tool === "team_send") result = team.send(by, args as any)
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name) else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
else if (st.tool === "team_assign") result = team.assign(by, args as any)
else if (st.tool === "team_replace") result = team.replace(by, args as any).text
else if (st.tool === "team_finish") result = team.finishTask(by, args.report)
else if (st.tool === "team_whoami") result = team.whoami(by)
else if (st.tool === "team_findings") result = args.action === "write" ? team.noteFinding(by, args.text ?? "") : team.readFindings(by, args.of)
else if (st.tool === "team_request") result = team.request(by, args as any, TOOL_SPECS[args.tool]?.risk as any)
else if (st.tool === "team_decide") result = team.decide(by, args as any)
else { else {
result = team.refusal(by, st.tool, args) ?? null result = team.refusal(by, st.tool, args) ?? null
if (st.tool === "task" && result === null && team.offered) team.helperStart(by) if (st.tool === "task" && result === null && team.offered) team.taskStart(by)
} }
return { result, delivered: delivered() } return { result, delivered: delivered() }
} }
case "finish": case "finish":
team.finish(by, st.reply ?? "", st.reason ?? "stop") team.finish(by, st.reply ?? "", st.reason ?? "stop")
return { delivered: delivered() } return { delivered: delivered() }
case "helper_done": case "task_done":
team.helperDoneBy(by) team.taskDoneBy(by)
return null return null
case "set": case "set":
if (st.capacity) capacity = st.capacity if (st.capacity) capacity = st.capacity
@@ -289,10 +325,65 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
} }
case "state": case "state":
return team.state() return team.state()
// Spec 3.4.0.
case "context":
team.setContext(by, st.used, st.window)
return null
case "context_full":
team.pause(by)
return { delivered: delivered() }
case "started":
return { text: started[by] ?? null }
case "tree": {
const t = team.info()
const row = (a: (typeof t.agents)[number]) => ({ id: a.id, name: a.name, parent: a.parent, state: a.state, mode: a.mode, request: a.request })
return { size: t.size, open_slots: t.open_slots, agents: t.agents.map(row), ended: t.ended.map((a) => ({ id: a.id, name: a.name, state: a.state })) }
}
case "main_mode":
team.setMainMode(st.mode)
return null
} }
throw new Error(`team: unknown op ${String(st.op)}`) throw new Error(`team: unknown op ${String(st.op)}`)
}) })
}, },
// Progress (progress.md, spec 3.4.0): the text and the line a client draws.
progress: (c) => {
const p = c.progress as Progress
return { text: progressText(p, c.now as number), line: progressLine(p, c.now as number) }
},
// Delegation (subagents.md §7, spec 3.4.0): the mode check, who decides a call, team_request's
// check, and tightening down the tree.
delegation: (c) => {
if (c.op === "add") {
const r = checkMode(c.parent_mode as Mode, c.mode as Mode | undefined, c.request as string[] | undefined)
return "error" in r ? { result: r.error, mode: null } : { result: null, mode: r.mode }
}
if (c.op === "call") {
const call = c.call as { tool: string; line?: string; path?: string }
const req: PermissionRequest =
call.tool === "bash"
? { permission: "bash", class: "execute", patterns: [call.line!], command: call.line!, paths: ["/project"] }
: { permission: call.tool === "read" ? "read" : "edit", class: call.tool === "read" ? "read" : "write", patterns: [call.path!], paths: [`/project/${call.path}`] }
const v = whoDecides(req, {
ctx: { rules: toRules(DEFAULT_RULES, "default"), hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" },
mode: c.mode as Mode,
main: c.main_mode as Mode,
request: (c.request as string[] | undefined) ?? [],
grants: (c.grants as Grant[] | undefined) ?? [],
unattended: c.unattended === true,
})
// The text is compared unless the case says the reason is each project's own.
return v.answer === "refused" && c.own_reason !== true ? { answer: v.answer, text: v.text } : { answer: v.answer }
}
if (c.op === "request") {
const cls = TOOL_SPECS[c.tool as string]?.risk as "read" | "write" | "execute" | undefined
return { result: requestRefusal(c.tool as string, cls, c.request as string[]) ?? fillText(LOOP.team.texts.requested, { id: `${LOOP.team.request.id_prefix}1` }) }
}
if (c.op === "switch") {
return tighten(c.main_to as Mode, c.agents as { name: string; parent: string; mode: Mode }[])
}
throw new Error(`delegation: unknown op ${String(c.op)}`)
},
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null), library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => { toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`. // The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+2 -1
View File
@@ -87,7 +87,8 @@ describe("context", () => {
await a.engine.prompt("read it") await a.engine.prompt("read it")
const system = a.engine.compose(a.engine.mode) const system = a.engine.compose(a.engine.mode)
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools)) const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
expect(text).toContain("window 32.8k") expect(text).toContain("/ 32.8k tokens")
expect(text).toMatch(/until auto-compact \(at 85%\) — \/compact to compact now/)
expect(text).toContain("system prompt") expect(text).toContain("system prompt")
expect(text).toMatch(/tool definitions \(\d+\)/) expect(text).toMatch(/tool definitions \(\d+\)/)
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
+1 -1
View File
@@ -54,7 +54,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit") expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares // knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run). // one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"]) expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "team_whoami", "team_findings", "team_decide", "team_assign", "team_replace", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
const ends = events.filter((e) => e.type === "tool_end") const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"]) expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id const sid = store.sessions(1)[0]!.id
+1 -1
View File
@@ -153,7 +153,7 @@ test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's
// plan_write's former name rather than a tool. // plan_write's former name rather than a tool.
expect(TOOL_SPECS.todo).toBeUndefined() expect(TOOL_SPECS.todo).toBeUndefined()
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"]) expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_read", "team_send", "team_stop", "web_fetch", "web_search", "write"] const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami", "web_fetch", "web_search", "write"]
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort()) expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
}) })
+6 -6
View File
@@ -197,7 +197,7 @@ describe("background subagents (spec 2.0.0)", () => {
], ],
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }], [{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
) )
await a.turns.prompt("look into it in the background") await a.turns.prompt("look into it in the background (subagents on g/m or g/n)")
const started = fake!.requests[1].messages.at(-1).content as string const started = fake!.requests[1].messages.at(-1).content as string
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).") expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
await settled(a, 3) await settled(a, 3)
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
{ chunks: [delta({ content: "Second report." })] }, { chunks: [delta({ content: "Second report." })] },
], ],
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1") expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
await settled(a, 6) await settled(a, 6)
const child = other!.requests const child = other!.requests
@@ -252,7 +252,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }], [{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }], [{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
const msgs = fake!.requests[1].messages const msgs = fake!.requests[1].messages
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message)) const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?") expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
@@ -278,7 +278,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }], [{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
{ asker }, { asker },
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }]) expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
}) })
@@ -297,7 +297,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }], [{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
{ oneAtATime: true }, { oneAtATime: true },
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string) const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1") expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there") expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
@@ -330,7 +330,7 @@ test("a cancelled worktree subagent keeps its branch, and the result names it",
const root = a.project.root const root = a.project.root
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args) for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
;(a.project as any).gitRoot = root ;(a.project as any).gitRoot = root
const turn = a.turns.prompt("fix it in a worktree") const turn = a.turns.prompt("fix it in a worktree (subagents on g/m or g/n)")
const until = Date.now() + 8000 const until = Date.now() + 8000
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20) while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
const write = events.find((e) => e.type === "sub_tool" && e.name === "write") const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
+207
View File
@@ -0,0 +1,207 @@
// Subagents v2 (harness spec 3.4.0, subagents.md §5–§14; progress.md) through the whole app: a
// subagent's team_finish ends its turn, a narrowed one is refused what only the main agent may
// allow and asks for it, a call nobody in the tree can allow reaches the person naming the
// subagent, a subagent whose context fills gets one final turn and ends, every engine says its
// progress as phase events, and an agent chat's tree goes over the link. The rules themselves are
// conformance/team.json's and delegation.json's.
import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp, type App } from "../src/app.ts"
import type { AskReply, Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { LOOP } from "../src/harness.ts"
import { AcpAgent } from "../src/acp/agent.ts"
import { Peer, type Transport } from "../src/acp/rpc.ts"
import { setTrust } from "../src/project/root.ts"
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
let fake: Fake | undefined
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
fake?.stop()
})
const T = LOOP.team
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown, usage?: { prompt_tokens: number; completion_tokens: number }): Scripted => ({
chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls"), ...(usage ? [{ choices: [], usage }] : [])],
})
const system = (body: any): string => body.messages[0].content
const memberName = (body: any): string | undefined => /You are "([a-z0-9-]+)" \(s\d+\), a subagent/.exec(system(body))?.[1]
const lastUser = (body: any): string => {
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
}
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
function setup(answer: (body: any) => Scripted | undefined, spec = "{ context: 32768, parallel: 4 }") {
fake = fakeProvider(answer)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-sub2-")))
Bun.spawnSync(["git", "init", "-q", dir])
setTrust(dir, "trusted")
return dir
}
async function until(what: () => boolean, ms = 10000) {
const end = Date.now() + ms
while (!what()) {
if (Date.now() > end) throw new Error("timed out")
await Bun.sleep(20)
}
}
/** The lead's script: the calls in order, then a closing line; each member's by its name. */
function scripted(lead: Scripted[], members: Record<string, Scripted[]>) {
const at: Record<string, number> = {}
return (body: any): Scripted => {
const who = memberName(body) ?? "lead"
const list = who === "lead" ? lead : (members[who] ?? [])
const n = (at[who] = (at[who] ?? -1) + 1)
return list[n] ?? say(`${who} is done.`)
}
}
describe("subagents v2 (spec 3.4.0)", () => {
test("team_finish reports to the parent and ends the subagent: nothing runs after that call", async () => {
const dir = setup(
scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], {
api: [call("f1", "team_finish", { report: "The API is done: src/api.ts." }), call("x1", "write", { path: "late.txt", content: "x" })],
}),
)
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
await app.engine.prompt("build it")
await until(() => app!.team.team.ended.length === 1)
await until(() => !app!.engine.busy)
const t = app.team.team
expect(t.ended[0]).toMatchObject({ id: "s1", name: "api", state: "done" })
expect(t.room.find((m) => m.report)?.text).toBe("The API is done: src/api.ts.")
// Its one request: nothing after the team_finish call.
expect(fake!.requests.filter((r) => memberName(r) === "api")).toHaveLength(1)
expect(await Bun.file(join(dir, "late.txt")).exists()).toBe(false)
}, 20000)
test("permissions: a narrowed subagent is told to ask the main agent; a call nobody in the tree can allow reaches the person, naming the subagent", async () => {
const asked: any[] = []
const dir = setup(
scripted(
[
call("a1", "team_add", { name: "docs", role: "the docs", task: "Write the docs.", goal: "docs", mode: "manual", request: ["write"] }),
call("a2", "team_add", { name: "ops", role: "the release", task: "Push it." }),
say("Both are on it."),
],
{
docs: [call("w1", "write", { path: "README.md", content: "# Docs\n" }), say("asked")],
ops: [call("b1", "bash", { command: "git push origin main" }), say("pushed or not")],
},
),
)
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (req): Promise<AskReply> => (asked.push(req), { kind: "deny" }) }, store: false })
await app.engine.prompt("go")
await until(() => fake!.requests.filter((r) => memberName(r) === "docs").length >= 2 && fake!.requests.filter((r) => memberName(r) === "ops").length >= 2)
const docs = fake!.requests.filter((r) => memberName(r) === "docs")
expect(toolResults(docs[1])[0]).toBe(T.texts.requestable)
// The docs subagent has team_request (it has request options); the ops subagent does not.
expect(docs[0].tools.map((x: any) => x.function.name)).toContain("team_request")
const ops = fake!.requests.filter((r) => memberName(r) === "ops")
expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_request")
expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_add")
// An all-edit tree: the subagent's push reaches the person, not the main agent.
expect(asked.map((r) => [r.tool, r.source?.subagent?.name])).toEqual([["bash", "ops"]])
expect(asked[0].source.subagent).toEqual({ id: "s2", name: "ops", position: "the release" })
expect(await Bun.file(join(dir, "README.md")).exists()).toBe(false)
}, 20000)
test("a subagent whose context fills is paused, gets one final turn, and ends; its parent gets the notice and its last reply", async () => {
const dir = setup(
scripted([call("a1", "team_add", { name: "scan", role: "scans the code", task: "Scan everything.", goal: "a map" }), say("scan is on it.")], {
scan: [
call("n1", "team_findings", { action: "write", text: "src/ has 12 modules." }, { prompt_tokens: 3700, completion_tokens: 20 }),
say("Saved my findings; the tests are left."),
],
}),
"{ context: 4096, parallel: 4 }",
)
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
await app.engine.prompt("map it")
await until(() => app!.team.team.ended.length === 1)
const t = app.team.team
expect(t.ended[0]).toMatchObject({ name: "scan", state: "out_of_context" })
expect(t.ended[0]!.findings.map((f) => f.text)).toEqual(["src/ has 12 modules."])
const scans = fake!.requests.filter((r) => memberName(r) === "scan")
// The final turn: its user message is the notice, under its mark.
expect(lastUser(scans[1])).toContain(T.texts.context_full.replace("{{steps}}", String(T.context.final_steps)))
const report = t.room.find((m) => m.report)!
expect(report.text).toBe(`${T.texts.gone.replace("{{name}}", "scan").replace("{{id}}", "s1").replace("{{name}}", "scan")}\n\nSaved my findings; the tests are left.`)
}, 20000)
test("every engine says its progress: reading the prompt, a tool, writing, and nothing once the turn is done", async () => {
const dir = setup(() => undefined)
let n = 0
fake!.stop()
fake = fakeProvider(() => (n++ === 0 ? call("l1", "list", {}) : say("Listed.")))
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: { context: 32768 }\n`, { mode: 0o600 })
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
const phases: (string | null)[] = []
app.bus.on((e: Event) => e.type === "phase" && phases.push(e.progress?.phase ?? null))
await app.engine.prompt("list it")
expect(phases).toContain("reading_prompt")
expect(phases).toContain("running_tool")
expect(phases).toContain("writing")
expect(phases.at(-1)).toBeNull()
expect(app.engine.progress.current).toBeNull()
}, 20000)
})
function pair(): [Transport, Transport] {
const make = () => ({ msg: (_: string) => {}, end: () => {} })
const a = make()
const b = make()
const side = (me: typeof a, o: typeof a): Transport => ({
send: (t) => queueMicrotask(() => o.msg(t)),
onMessage: (fn) => (me.msg = fn),
onClose: (fn) => (me.end = fn),
close: () => (me.end(), o.end()),
})
return [side(a, b), side(b, a)]
}
test("an agent chat's tree over the link: the setting comes down with the turn; the tree, progress, findings and transcript come up", async () => {
const dir = setup(
scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], {
api: [call("n1", "team_findings", { action: "write", text: "Login is POST /session." }), say("API done.")],
}),
)
const [ta, tb] = pair()
new AcpAgent(new Peer(ta))
const c = new Peer(tb)
const events: any[] = []
c.on("_lembas/event", (p) => void events.push(p.event))
c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } }))
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 3 } } } })
expect(init._meta.lembas.capabilities).toEqual(expect.arrayContaining(["progress", "tree", "agents"]))
const s: any = await c.request("session/new", { cwd: dir, mcpServers: [], _meta: { lembas: { agents: { min: 1, max: 3 }, said: ["Use the main model only."] } } })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "build it" }], _meta: { lembas: { agents: { min: 1, max: 3 } } } })
await until(() => events.some((e) => e.type === "team" && e.tree?.agents.some((a: any) => a.name === "api" && a.state === "finished")))
const view: any = await c.request("_lembas/team", { sessionId: s.sessionId })
expect(view.tree).toMatchObject({ min: 1, max: 3, size: 2 })
expect(view.tree.agents.map((a: any) => [a.id, a.name, a.parent])).toEqual([
["lead", "lead", null],
["s1", "api", "lead"],
])
expect(view.room.map((m: any) => [m.from, m.kind])).toEqual([["api", "report"]])
expect(await c.request("_lembas/team/findings", { sessionId: s.sessionId, id: "s1" })).toMatchObject({ id: "s1", findings: [{ n: 1, text: "Login is POST /session." }] })
const tr: any = await c.request("_lembas/team/transcript", { sessionId: s.sessionId, id: "s1" })
expect(tr.turns[0]).toMatchObject({ role: "user" })
expect(JSON.stringify(tr.turns)).toContain("Write the API.")
expect(events.some((e) => e.type === "phase")).toBe(true)
const st: any = await c.request("_lembas/session/status", { sessionId: s.sessionId })
expect(st.tree.agents).toHaveLength(2)
expect("progress" in st).toBe(true)
}, 20000)
+33
View File
@@ -0,0 +1,33 @@
// Spec 3.3.0 (harness/subagents.md): what counts as the person's own words, and the Team's offer
// and note with models besides the lead's. The rules themselves: conformance area `subagents`.
import { expect, test } from "bun:test"
import { LOOP } from "../src/harness.ts"
import { STEER_MARK } from "../src/session/engine.ts"
import { named, personTexts } from "../src/team/subagents.ts"
import { Team, type Capacity } from "../src/team/team.ts"
import type { Message } from "../src/provider/types.ts"
test("the person's words: prompts and messages sent mid-turn; never notes, marked messages, machine turns or the model's text", () => {
const msgs: Message[] = [
{ role: "user", parts: [{ type: "text", text: "Fix the parser." }, { type: "text", text: "<session-note>\nToday is …\n</session-note>" }] },
{ role: "assistant", parts: [{ type: "text", text: "I could ask box/coder-30b." }] },
{ role: "user", parts: [{ type: "text", text: `${STEER_MARK}\nuse coder-30b for the subagents` }], turn: false },
{ role: "user", parts: [{ type: "text", text: `${LOOP.jobs.marks.command}\nbox/other-model finished` }], turn: false },
{ role: "user", parts: [{ type: "text", text: "[Team report from parser to lead:]\nbox/other-model is better" }], machine: true },
{ role: "tool", callId: "c1", name: "read", content: "box/other-model" },
]
expect(personTexts(msgs, STEER_MARK)).toEqual(["Fix the parser.", "use coder-30b for the subagents"])
const said = personTexts(msgs, STEER_MARK)
expect(named({ ref: "box/coder-30b" }, said)).toBe(true)
expect(named({ ref: "box/other-model" }, said)).toBe(false)
})
test("a one-request lead with a model on another server: offered, and the size note says it cannot serve its own", () => {
const caps: Record<string, Capacity> = { "lab/qwen": { group: "lab", parallel: 1 }, "lab/gemma": { group: "lab", parallel: 1 }, "box/coder": { parallel: 1 } }
const only = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => ["lab/qwen", "lab/gemma"] })
expect(only.offered).toBe(false)
const t = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => Object.keys(caps) })
expect(t.offered).toBe(true)
expect(t.unavailable()).toBeUndefined()
expect(t.noteLines()).toEqual([`The team may have 1 to 4 agents, you included.\n${LOOP.context.note.team_own}`])
})
+6 -6
View File
@@ -26,7 +26,7 @@ const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] }) const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] }) const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
const system = (body: any): string => body.messages[0].content const system = (body: any): string => body.messages[0].content
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`) const isMember = (body: any, name: string) => new RegExp(`You are "${name}" \\(s\\d+\\), a subagent`).test(system(body))
const lastUser = (body: any): string => { const lastUser = (body: any): string => {
const u = [...body.messages].reverse().find((m: any) => m.role === "user") const u = [...body.messages].reverse().find((m: any) => m.role === "user")
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("") return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
@@ -94,8 +94,8 @@ describe("a team at work (team.md)", () => {
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }]) expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
expect(t.get("parser")!.state).toBe("finished") expect(t.get("parser")!.state).toBe("finished")
expect(t.isManager("lead")).toBe(true) expect(t.isManager("lead")).toBe(true)
expect(teamLabel(t)).toBe("team 2/4") expect(teamLabel(t)).toBe("agents 2/4")
expect(teamText(app, "")).toContain("- parser — the parser · finished") expect(teamText(app, "")).toContain("- parser (s1) — the parser · finished")
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false) expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
// A member is not a job to the model. // A member is not a job to the model.
expect(app.jobs().every((j) => j.team === "parser")).toBe(true) expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
@@ -135,10 +135,10 @@ describe("a team at work (team.md)", () => {
const dir = setup(() => say(`ok ${n++}`)) const dir = setup(() => say(`ok ${n++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false }) app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one") await app.engine.prompt("one")
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32") expect(teamText(app, "size 3 2")).toContain("1 ≤ Min ≤ Max ≤ 32")
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32") expect(teamText(app, "size 1 40")).toContain("1 ≤ Min ≤ Max ≤ 32")
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4]) expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
expect(teamText(app, "size 2 6")).toContain("2 to 6") expect(teamText(app, "size 2 6")).toContain("Min 2, Max 6")
expect(app.settings.get("agents.max").value).toBe(6) expect(app.settings.get("agents.max").value).toBe(6)
await app.engine.prompt("two") await app.engine.prompt("two")
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.") expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
+7 -5
View File
@@ -372,14 +372,14 @@ test("thinking is one line — its text is not shown; the provider's count of it
expect(await t.until("SECRET-INNER-STEP")).toBe(true) expect(await t.until("SECRET-INNER-STEP")).toBe(true)
}) })
test("while it thinks: the thought says only Thinking…; the line at the bottom has the verb, the task's time and tokens", async () => { test("while it thinks: the thought says only Thinking…; the line at the bottom has the phase (progress.md), the task's time and tokens", async () => {
t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }]) t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }])
await t.input.typeText("go") await t.input.typeText("go")
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("~15 tokens")).toBe(true) expect(await t.until("~15 tokens")).toBe(true)
const frame = t.frame() const frame = t.frame()
expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m) expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m)
expect(frame).toMatch(/(pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|reasoning|analyzing|synthesizing|formulating)… \d+s · ~15 tokens · esc to stop/) expect(frame).toMatch(/Thinking · \d+s · turn \d+s · ~15 tokens · esc to stop/)
expect(frame).not.toContain("HIDDEN-WHILE-THINKING") expect(frame).not.toContain("HIDDEN-WHILE-THINKING")
expect(await t.until("Done thinking.")).toBe(true) expect(await t.until("Done thinking.")).toBe(true)
expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/) expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/)
@@ -397,7 +397,7 @@ test("the line at the bottom counts the whole task: the clock and the tokens go
// The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the // The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the
// frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this // frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this
// state, so wait for the line within it rather than reading the first frame. // state, so wait for the line within it rather than reading the first frame.
const counted = /writing… \d+s · ~4\d tokens · esc to stop/ const counted = /Writing(: [\d.]+ tok\/s)? · \d+s · turn \d+s · ~4\d tokens · esc to stop/
for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle() for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle()
expect(t.frame()).toMatch(counted) expect(t.frame()).toMatch(counted)
expect(await t.until("way.")).toBe(true) expect(await t.until("way.")).toBe(true)
@@ -922,10 +922,12 @@ test("while the server reads a long prompt: the line at the bottom says how far
t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }]) t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }])
await t.input.typeText("go") await t.input.typeText("go")
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("reading the prompt 64% · 90k of 140k · ~1m 40s left")).toBe(true) // At 100 columns the line is cut in its middle; its start and its end are what is checked.
expect(await t.until("~1m 40s left")).toBe(true)
expect(t.frame()).toContain("Reading the prompt: 90000 / 140000 tokens (64%")
expect(await t.until("Read it all.")).toBe(true) expect(await t.until("Read it all.")).toBe(true)
await t.settle() await t.settle()
expect(t.frame()).not.toContain("reading the prompt") expect(t.frame()).not.toContain("Reading the prompt")
}) })
test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => { test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {
+47
View File
@@ -36,3 +36,50 @@ test("dialog: arrow down + enter picks the second option — with a spec that cl
expect(picked).toEqual(["b"]) expect(picked).toEqual(["b"])
expect(closed).toBe(1) expect(closed).toBe(1)
}) })
test("dialog: a range row is one track with two handles — ←→ move the one in focus, tab the other, enter hands both over", async () => {
const set: [number, number][] = []
const picked: string[] = []
const ctx = { theme: () => DEFAULT_THEME, dims: () => ({ width: 72, height: 16 }) } as never
const [spec, setSpec] = createSignal<DialogSpec | undefined>({
title: "Effort",
options: [
{ label: "high", value: "high", current: true },
{ label: "Subagents", value: "team", description: "1 is this agent alone", range: { from: 1, to: 32, lo: 1, hi: 4, names: ["Min", "Max"], onSubmit: (lo, hi) => set.push([lo, hi]) } },
],
onSelect: (v) => picked.push(v),
})
setup = await testRender(
() => (
<TuiContext.Provider value={ctx}>
<Show when={spec()}>
<Dialog spec={spec()!} close={() => setSpec(undefined)} />
</Show>
</TuiContext.Provider>
),
{ width: 72, height: 16 },
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Subagents Min 1 ●")
setup.mockInput.pressArrow("down")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("←→ move Max · tab Min")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Max 6")
setup.mockInput.pressTab()
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("left")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 2")
// Min never passes Max, and Min never goes under 1.
for (let i = 0; i < 10; i++) setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 6")
setup.mockInput.pressEnter()
await setup.renderOnce()
expect(set).toEqual([[6, 6]])
expect(picked).toEqual([])
})