Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0fa62a51
|
||
|
|
f3db970c9d
|
||
|
|
ae1201540a
|
||
|
|
b70cfe483b
|
||
|
|
ecfd1b0b8a
|
||
|
|
8c5edc0f01
|
||
|
|
b66891e186
|
||
|
|
6199028358
|
||
|
|
4b8786b7f6
|
No files matched your search
+41
-1
@@ -4,7 +4,47 @@ All notable changes to LLeMbas CLI. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
|
||||
|
||||
+38
-3
@@ -15,18 +15,19 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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/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 |
|
||||
| `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; `subagents` — the model refusals, what names a model, the setting's label; `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -70,6 +71,40 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
||||
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.
|
||||
|
||||
## 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`).
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.3.0
|
||||
3.4.0
|
||||
+87
-5
@@ -65,6 +65,9 @@ change to the protocol is a change here first, then in both projects.
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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`.
|
||||
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
||||
`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:
|
||||
|
||||
| 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_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 |
|
||||
| `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
|
||||
|
||||
@@ -301,7 +359,7 @@ Only on the link (an error on `serve --stdio`).
|
||||
|
||||
### `_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.
|
||||
|
||||
### `_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
|
||||
|
||||
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
|
||||
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:
|
||||
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).
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1617
-44
File diff suppressed because it is too large.
Load diff
+204
-12
@@ -84,7 +84,7 @@
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"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): 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": {
|
||||
"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,
|
||||
@@ -92,17 +92,24 @@
|
||||
"cap": 32
|
||||
},
|
||||
"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}$",
|
||||
"lead": "lead",
|
||||
"reserved": [
|
||||
"all",
|
||||
"lead"
|
||||
]
|
||||
"lead",
|
||||
"parent"
|
||||
],
|
||||
"parent": "parent",
|
||||
"id_prefix": "s"
|
||||
},
|
||||
"marks": {
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -153,25 +160,106 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"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):",
|
||||
"agent": "{{indent}}- {{name}} — {{role}} · {{state}} · {{model}} · {{last}}",
|
||||
"lead_role": "leads the team",
|
||||
"agent": "{{indent}}- {{name}} ({{id}}) — {{role}} · {{state}} · {{model}} · {{mode}} · {{context}} · {{last}}",
|
||||
"lead_role": "the main agent",
|
||||
"states": {
|
||||
"working": "working",
|
||||
"waiting": "waiting for a slot",
|
||||
"finished": "finished",
|
||||
"idle": "idle"
|
||||
"idle": "idle",
|
||||
"paused": "paused: context full",
|
||||
"done": "done",
|
||||
"out_of_context": "out of context",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"active": "active now",
|
||||
"room": "Room since #{{since}}:",
|
||||
"message": "#{{n}} {{from}} → {{to}}: {{text}}",
|
||||
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
|
||||
"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": {
|
||||
@@ -239,12 +327,116 @@
|
||||
"personality": "The person chose a different personality:\n{{personality}}",
|
||||
"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_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_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.",
|
||||
"empty": "(none)"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@
|
||||
"team_send": "allow",
|
||||
"team_read": "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",
|
||||
"memory": "allow",
|
||||
"session_search": "allow",
|
||||
|
||||
@@ -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,12 +1,14 @@
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): how to lead a team, in the system prompt whenever
|
||||
the team tools are offered, and in a member's that may lead. Constant: the size the person
|
||||
allows comes in a session note, never here. -->
|
||||
# Leading a team
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
- Step in when a member is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns.
|
||||
- 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.
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §5–§13): how the main agent
|
||||
leads its subagents, in the system prompt whenever the team tools are offered. Constant: the
|
||||
size the person allows comes in a session note, never here. -->
|
||||
# Leading subagents
|
||||
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 subagent owns a thing from start to finish.
|
||||
- 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -1,14 +1,16 @@
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): a team member's standing, said once at the end
|
||||
of its system prompt in place of blocks/subagent.md. The variables are what differs per member;
|
||||
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}}".
|
||||
Your part: {{role}}
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §13): a subagent's standing,
|
||||
said once at the end of its system prompt in place of blocks/subagent.md. The variables are what
|
||||
differs per subagent; each stands on a line of its own, so no punctuation of theirs matters. -->
|
||||
You are "{{name}}" ({{id}}), a subagent of this session, and you report to "{{manager}}".
|
||||
Your position: {{role}}
|
||||
The team's goal: {{goal}}
|
||||
|
||||
{{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}}
|
||||
@@ -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.
|
||||
@@ -251,16 +251,23 @@
|
||||
"scope": "shared",
|
||||
"llembas": [],
|
||||
"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": {
|
||||
"name": "the member's name in the team",
|
||||
"role": "its role as team_add gave it: one line, what it owns",
|
||||
"manager": "the name of the agent that added it (`lead` for the team's lead)",
|
||||
"role": "its position, one line (team_add's role)",
|
||||
"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)",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+273
-1
@@ -1,6 +1,6 @@
|
||||
# Subagents
|
||||
|
||||
Spec 3.3.0. **Which model a subagent runs on, and when subagents are offered at all.** A
|
||||
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`
|
||||
@@ -88,3 +88,275 @@ 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`).
|
||||
|
||||
+15
-1
@@ -1,6 +1,6 @@
|
||||
# Teams
|
||||
|
||||
Spec 3.2.0; the model a member runs on and when a team is offered, 3.3.0 (`subagents.md`). **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
|
||||
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
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
|
||||
@@ -243,6 +253,10 @@ agent not under it (`not_yours`). The answer, `stopped`, names the stopped in tr
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"cli": [],
|
||||
"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, you included; 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"role": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "One line: what it owns"
|
||||
"description": "Its position, one line: what it owns"
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"lead": {
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -48,6 +48,32 @@
|
||||
"model": {
|
||||
"type": "string",
|
||||
"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": [
|
||||
@@ -58,5 +84,6 @@
|
||||
},
|
||||
"variables": {
|
||||
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
|
||||
}
|
||||
},
|
||||
"offered": "main"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"cli": [],
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"cli": [],
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"description": "Names, or [\"all\"]"
|
||||
"description": "Names, [\"parent\"], or [\"all\"]"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
|
||||
@@ -14,18 +14,19 @@
|
||||
"cli": [],
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A member under you, or all"
|
||||
"description": "A subagent's name, or all"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"offered": "main"
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lembas-cli",
|
||||
"version": "2.0.0-beta.5",
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
+49
-2
@@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
|
||||
import type { Message } from "../provider/types.ts"
|
||||
import type { Hub } from "./hub.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"
|
||||
|
||||
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
|
||||
// said it can show files; the device sends the file with `_lembas/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
|
||||
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 } : {}),
|
||||
// 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 } } : {}),
|
||||
// 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/send", (p) => this.job("send", 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/resize", (p) => this.terminalResize(p))
|
||||
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
||||
@@ -675,6 +687,8 @@ export class AcpAgent {
|
||||
const effort = effortOf(p?.effort)
|
||||
// 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)
|
||||
// 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 }
|
||||
}
|
||||
|
||||
@@ -724,6 +738,19 @@ export class AcpAgent {
|
||||
private takeContext(s: Session, p: any) {
|
||||
const ctx = contextOf(p?._meta)
|
||||
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
|
||||
@@ -1067,6 +1094,8 @@ export class AcpAgent {
|
||||
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
||||
plan: s.app.engine.plan ?? null,
|
||||
jobs: jobList(s.app),
|
||||
progress: s.app.engine.progress.current,
|
||||
tree: treeOf(s.app),
|
||||
}
|
||||
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.
|
||||
@@ -1091,6 +1120,24 @@ export class AcpAgent {
|
||||
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. */
|
||||
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
||||
const t = this.terminalFor(p)
|
||||
@@ -1291,7 +1338,7 @@ export class AcpAgent {
|
||||
private forward(s: Session, e: Event) {
|
||||
// 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.
|
||||
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 })
|
||||
switch (e.type) {
|
||||
case "text":
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { App } from "../app.ts"
|
||||
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
||||
import type { Store } from "../session/store.ts"
|
||||
import type { TreeInfo } from "../team/team.ts"
|
||||
import { historyTurns } from "./agent.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")]
|
||||
}
|
||||
|
||||
// ── 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
@@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||
import { readLevel } from "../provider/effort.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 { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
||||
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
||||
@@ -454,7 +454,7 @@ export class Share {
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -520,6 +520,15 @@ export class Share {
|
||||
return jobSend(this.app, p?.id, p?.text)
|
||||
case "transcript":
|
||||
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:
|
||||
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
||||
}
|
||||
|
||||
+57
-17
@@ -46,7 +46,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
||||
import type { McpServer } from "./config/schema.ts"
|
||||
import { embedderFor } from "./library/embed.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"
|
||||
|
||||
export interface AppOptions {
|
||||
@@ -210,6 +211,8 @@ function modelInfo(m: ResolvedModel): ModelInfo {
|
||||
|
||||
/** 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))
|
||||
/** 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 }
|
||||
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
|
||||
@@ -370,7 +373,7 @@ export function createApp(o: AppOptions): App {
|
||||
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 }
|
||||
}
|
||||
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
|
||||
return assembleSystem({
|
||||
modelRef: current.ref,
|
||||
@@ -523,13 +526,17 @@ export function createApp(o: AppOptions): App {
|
||||
}
|
||||
: perm
|
||||
// `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
|
||||
// (team.md §4); a task subagent has none of them.
|
||||
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
|
||||
// A subagent of the tree also has its team tools (spec 3.4.0 §8: whoami, read, send, findings,
|
||||
// finish, and request with request options) — never the main agent's (`offered: main`); a task
|
||||
// subagent has none of them.
|
||||
const memberTools = member ? memberToolsOf(member) : []
|
||||
const isTeamTool = (n: string) => (TEAM_TOOLS as readonly string[]).includes(n)
|
||||
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))),
|
||||
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(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) && (!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 ownerSession = jobsSessionId()
|
||||
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
||||
@@ -539,7 +546,7 @@ export function createApp(o: AppOptions): App {
|
||||
let job: Job
|
||||
// The asker is the session's; a child's ask says which subagent it comes from.
|
||||
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() {
|
||||
return o.asker.question
|
||||
},
|
||||
@@ -555,9 +562,9 @@ export function createApp(o: AppOptions): App {
|
||||
tools: childTools,
|
||||
store,
|
||||
sessionId: childRow?.id,
|
||||
// An agent's own mode can make it stricter than its parent, never looser.
|
||||
// A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode.
|
||||
mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode),
|
||||
// An agent's own mode can make it stricter than its parent, never looser. A subagent of the
|
||||
// tree runs in the mode the main agent gave it (spec 3.4.0 §7), set above.
|
||||
mode: member ? member.mode : stricterMode(spec.mode, engine.mode),
|
||||
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
||||
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
||||
compaction,
|
||||
@@ -574,6 +581,13 @@ export function createApp(o: AppOptions): App {
|
||||
fix: () => teamRt.team.offered,
|
||||
offered: () => teamRt.team.offered,
|
||||
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),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -596,7 +610,7 @@ export function createApp(o: AppOptions): App {
|
||||
},
|
||||
at,
|
||||
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
|
||||
@@ -619,7 +633,7 @@ export function createApp(o: AppOptions): App {
|
||||
const id = teamRt.team.taskStart(TEAM.names.lead, m.ref)
|
||||
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 = {
|
||||
bus: childBus,
|
||||
engine: child,
|
||||
@@ -631,15 +645,26 @@ export function createApp(o: AppOptions): App {
|
||||
if (member) {
|
||||
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
|
||||
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)
|
||||
void run(text, machine).then((r) => {
|
||||
void run(text, machine, steps).then((r) => {
|
||||
const leftover = [...child.takeNotes(), ...child.takeInbox()]
|
||||
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
|
||||
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)
|
||||
memberRun(prompt, false)
|
||||
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
|
||||
@@ -713,6 +738,8 @@ export function createApp(o: AppOptions): App {
|
||||
models: usableModels,
|
||||
leadState: "idle",
|
||||
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({
|
||||
team: newTeam(),
|
||||
@@ -725,7 +752,12 @@ export function createApp(o: AppOptions): App {
|
||||
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) => {
|
||||
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
||||
try {
|
||||
@@ -740,6 +772,14 @@ export function createApp(o: AppOptions): App {
|
||||
})
|
||||
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
|
||||
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).
|
||||
let mcpNames = new Set<string>()
|
||||
|
||||
+5
-2
@@ -47,7 +47,7 @@ export type Event =
|
||||
| { type: "steered"; texts: string[]; ids?: string[] }
|
||||
/** 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: "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. */
|
||||
| { type: "retract" }
|
||||
/** 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
|
||||
* message in the room. Read the team itself for what. */
|
||||
| { 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). */
|
||||
| { type: "mode"; mode: Mode }
|
||||
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
||||
@@ -127,7 +130,7 @@ export interface Asker {
|
||||
purpose?: string
|
||||
/** 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. */
|
||||
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>
|
||||
/** 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. */
|
||||
|
||||
+54
-7
@@ -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_send from "../harness/tools/team_send.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_web_fetch from "../harness/tools/web_fetch.json"
|
||||
import t_web_search from "../harness/tools/web_search.json"
|
||||
@@ -76,8 +83,9 @@ export interface ToolSpecDef {
|
||||
purpose: string | false
|
||||
/** Names each project used before spec v1; a call under one is read as this tool. */
|
||||
aliases: { cli: string[]; llembas: string[] }
|
||||
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
|
||||
offered?: "subagent"
|
||||
/** `subagent`: offered only to a subagent (report, team_finish, team_request), never to a
|
||||
* 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>
|
||||
/** 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);
|
||||
@@ -128,13 +136,52 @@ export const LOOP: {
|
||||
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
||||
team: {
|
||||
agents: { min: number; max: number; cap: number }
|
||||
names: { pattern: string; lead: string; reserved: string[] }
|
||||
marks: { message: string; report: string }
|
||||
names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
|
||||
marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
|
||||
unavailable: { one_agent: string; one_request: string }
|
||||
work: { write: string[]; execute: string[]; kept: string[] }
|
||||
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>
|
||||
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 }
|
||||
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"
|
||||
// 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
|
||||
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
||||
@@ -254,7 +301,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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 blockTeamLead from "../../harness/prompts/blocks/team_lead.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 agentGeneral from "../../harness/prompts/agents/general.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/team_lead.md": blockTeamLead,
|
||||
"blocks/team_member.md": blockTeamMember,
|
||||
"blocks/team_submanager.md": blockTeamSubmanager,
|
||||
"agents/explore.md": agentExplore,
|
||||
"agents/general.md": agentGeneral,
|
||||
"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.
|
||||
* `report`: it has the report tool. */
|
||||
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,
|
||||
* with the whole of blocks/team_lead.md for one that may lead. */
|
||||
member?: { name: string; manager: string; role: string; goal: string; lead: boolean }
|
||||
/** Spec 3.2.0 (team.md §4), 3.4.0: a subagent of the tree — blocks/team_member.md in place of
|
||||
* blocks/subagent.md, with blocks/team_submanager.md for a sub-manager. */
|
||||
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):
|
||||
* blocks/team_lead.md is in its prompt. */
|
||||
teamLead?: boolean
|
||||
@@ -391,12 +393,14 @@ export function assembleSystem(ctx: PromptContext): string {
|
||||
sections.push(
|
||||
fillPrompt(promptText("blocks/team_member.md"), {
|
||||
name: ctx.member.name,
|
||||
id: ctx.member.id,
|
||||
manager: ctx.member.manager,
|
||||
role: ctx.member.role,
|
||||
goal: ctx.member.goal,
|
||||
instructions: ctx.subagent.instructions,
|
||||
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")
|
||||
.trim(),
|
||||
|
||||
@@ -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).
|
||||
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
||||
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))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export type StreamEvent =
|
||||
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
||||
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
||||
/** 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 {
|
||||
system: string
|
||||
|
||||
+40
-2
@@ -18,6 +18,8 @@ import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||
import { CONTINUE_PROMPT } from "./engine.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"
|
||||
|
||||
@@ -49,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: "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: "team", description: "subagents: the team and the room; how many; stop one", 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
|
||||
@@ -60,6 +62,34 @@ export function teamLabel(team: Team): string {
|
||||
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
|
||||
* person sees everything, team.md §5); its size for this session; a member stopped by the person,
|
||||
* which the lead is told in a note. */
|
||||
@@ -83,6 +113,11 @@ export function teamText(app: App, arg: string): string {
|
||||
}
|
||||
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") {
|
||||
const name = rest[0]
|
||||
if (!name) return "/team stop <name> — a member and everything under it"
|
||||
@@ -95,7 +130,10 @@ export function teamText(app: App, arg: string): string {
|
||||
: "No subagents in this session: they are switched off (subagents: false)."
|
||||
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||
const own = t.couldServe().own ? "" : `\n${S.own}`
|
||||
return `${t.read("lead", since)}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a member.${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
|
||||
|
||||
+61
-11
@@ -8,7 +8,7 @@ import { isAbsolute } from "node:path"
|
||||
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
||||
import type { Mode } from "../config/schema.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 type { Plan } from "../plan/plan.ts"
|
||||
import { LOOP } from "../harness.ts"
|
||||
@@ -23,6 +23,8 @@ import { duration } from "../duration.ts"
|
||||
import { memoryResults, presentIds, ResultContext } from "./results.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"
|
||||
|
||||
export interface EngineOptions {
|
||||
@@ -78,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. */
|
||||
forget?(): void
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +98,8 @@ export interface PromptMeta {
|
||||
attachments?: AttachmentInfo[]
|
||||
/** The harness started this turn — a job's note — not the person. */
|
||||
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
|
||||
@@ -187,7 +200,11 @@ export class Engine {
|
||||
/** The prompt the team's offer was last decided for. */
|
||||
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) {
|
||||
this.progress = new ProgressTracker(o.bus)
|
||||
this.mode = o.mode
|
||||
this._effort = o.effort
|
||||
this.model = o.model
|
||||
@@ -464,8 +481,19 @@ export class Engine {
|
||||
/** 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
|
||||
* 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)
|
||||
// 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
|
||||
if (!window || autoAt >= 1) return
|
||||
const limit = window * autoAt
|
||||
@@ -482,6 +510,7 @@ export class Engine {
|
||||
}
|
||||
this.autoCompactions++
|
||||
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 {
|
||||
await this.summarise(signal)
|
||||
// The task is never left to the summary: the prompt that started this turn comes back word
|
||||
@@ -508,11 +537,13 @@ export class Engine {
|
||||
|
||||
/** 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. */
|
||||
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 back = this.progress.hold({ phase: "waiting_for_person", what, ...(tool ? { tool } : {}) })
|
||||
try {
|
||||
return await p
|
||||
} finally {
|
||||
back()
|
||||
this.turnWaitedMs += performance.now() - t0
|
||||
}
|
||||
}
|
||||
@@ -616,9 +647,13 @@ export class Engine {
|
||||
* 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 personTexts(all, this.o.steerMark ?? STEER_MARK)
|
||||
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.
|
||||
* `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"> {
|
||||
@@ -626,13 +661,13 @@ export class Engine {
|
||||
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 } : {}) })
|
||||
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 {
|
||||
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
|
||||
this.abort = new AbortController()
|
||||
const signal = this.abort.signal
|
||||
@@ -656,7 +691,7 @@ export class Engine {
|
||||
this.turnSteers = []
|
||||
this.autoCompactions = 0
|
||||
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()
|
||||
this.turnWaitedMs = 0
|
||||
this.turnOutputBytes = 0
|
||||
@@ -674,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` })
|
||||
}
|
||||
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.
|
||||
if (!wrapUp) this.deliverInbox()
|
||||
bus.emit({ type: "step", n: step })
|
||||
@@ -817,11 +855,11 @@ export class Engine {
|
||||
...this.o.toolCtx,
|
||||
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: {
|
||||
get: () => this.plan,
|
||||
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) => {
|
||||
this.mode = m
|
||||
@@ -881,6 +919,11 @@ export class Engine {
|
||||
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||
// results works everywhere.
|
||||
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) {
|
||||
bus.emit({ type: "done", reason: "cancelled" })
|
||||
return "cancelled"
|
||||
@@ -951,6 +994,13 @@ export class Engine {
|
||||
let edited: string | undefined
|
||||
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
|
||||
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.
|
||||
const key = `${call.name} ${JSON.stringify(args)}`
|
||||
@@ -967,7 +1017,7 @@ export class Engine {
|
||||
(diff) => ({ diff }),
|
||||
(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)
|
||||
return {
|
||||
result: {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
|
||||
// A lead session's team at work (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13): the
|
||||
// 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
|
||||
// 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.
|
||||
@@ -8,10 +8,14 @@ import { TOOL_SPECS } from "../harness.ts"
|
||||
import type { Engine } from "../session/engine.ts"
|
||||
import type { Tool } from "../tool/tool.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. */
|
||||
export interface MemberHandle {
|
||||
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). */
|
||||
run(text: string): void
|
||||
/** 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`. */
|
||||
spawnMember(member: TeamAgent, text: string): void
|
||||
/** 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
|
||||
* model's ref as the session knows it. */
|
||||
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 {
|
||||
@@ -36,6 +42,11 @@ export class TeamRuntime {
|
||||
private members = new Map<string, MemberHandle>()
|
||||
/** The lead's turn waiting for a slot: resolved when it is given one. */
|
||||
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) {
|
||||
this.team = o.team
|
||||
@@ -47,6 +58,8 @@ export class TeamRuntime {
|
||||
start: (name, text) => this.start(name, text),
|
||||
deliver: (name, text) => this.deliver(name, text),
|
||||
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()
|
||||
}
|
||||
|
||||
/** Said on the lead's bus as `team`, at most four times a second (acp.md `team` event). */
|
||||
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.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. */
|
||||
@@ -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'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 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.
|
||||
@@ -111,6 +198,7 @@ export class TeamRuntime {
|
||||
private stopped(name: string) {
|
||||
const h = this.members.get(name)
|
||||
this.members.delete(name)
|
||||
this.finals.delete(name)
|
||||
h?.cancel()
|
||||
this.changed()
|
||||
}
|
||||
@@ -125,6 +213,7 @@ export class TeamRuntime {
|
||||
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.lead.waiting?.(this.team.model)
|
||||
this.changed()
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const onAbort = () => {
|
||||
@@ -147,8 +236,21 @@ export class TeamRuntime {
|
||||
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[]) {
|
||||
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
|
||||
this.team.finish(name, reply, reason)
|
||||
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`. */
|
||||
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
|
||||
// says readOnlyHint, interact for the server's memory, else execute.
|
||||
let risk: string | undefined
|
||||
|
||||
+425
-25
@@ -1,5 +1,7 @@
|
||||
// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives
|
||||
// what, the size the person allows, a manager's refusals, and the slots of the model they run on.
|
||||
// Teams (harness spec 3.2.0, harness/team.md; 3.4.0, harness/subagents.md §5–§13): the main agent
|
||||
// (`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
|
||||
// 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
|
||||
@@ -9,14 +11,42 @@ import { fillPrompt } from "../prompt/fill.ts"
|
||||
import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts"
|
||||
import { BUILTIN_HARDLINE } from "../permission/hardline.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
|
||||
const TEXTS = TEAM.texts
|
||||
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)
|
||||
|
||||
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). */
|
||||
export interface Capacity {
|
||||
@@ -32,11 +62,26 @@ export function parallelFrom(c: Capacity | undefined): number {
|
||||
}
|
||||
|
||||
export interface TeamAgent {
|
||||
/** `lead` for the main agent; s1, s2, … for subagents, never reused (spec 3.4.0). */
|
||||
id: string
|
||||
name: string
|
||||
/** Its position (team_add's role). */
|
||||
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. */
|
||||
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
|
||||
/** The team's goal as it was told it (the lead's: the last one it gave). */
|
||||
goal: string
|
||||
@@ -54,6 +99,13 @@ export interface TeamAgent {
|
||||
ended?: number
|
||||
/** Added in this order (tree order among siblings). */
|
||||
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 {
|
||||
@@ -65,6 +117,38 @@ export interface RoomMessage {
|
||||
text: string
|
||||
time: number
|
||||
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 {
|
||||
@@ -75,6 +159,11 @@ export interface TeamEffects {
|
||||
deliver(name: string, text: string): void
|
||||
/** It left the team: its turn stops where it is, its background commands with it. */
|
||||
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 {
|
||||
@@ -95,6 +184,10 @@ export interface TeamOptions {
|
||||
now?: () => number
|
||||
/** The lead's state as the team begins: working (its first prompt runs) or 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. */
|
||||
@@ -125,6 +218,11 @@ export class Team {
|
||||
/** Members the person stopped, not yet said to the lead. */
|
||||
private stoppedByPerson: string[] = []
|
||||
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) {
|
||||
this.cap = o.cap ?? TEAM.agents.cap
|
||||
@@ -133,7 +231,7 @@ export class Team {
|
||||
this.min = TEAM.agents.min
|
||||
this.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()
|
||||
}
|
||||
|
||||
@@ -263,8 +361,14 @@ export class Team {
|
||||
/** 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). */
|
||||
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 (tool === "task") return this.taskRefusal()
|
||||
if ((TEAM_TOOLS as readonly string[]).includes(tool)) {
|
||||
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
|
||||
return undefined
|
||||
}
|
||||
@@ -336,6 +440,8 @@ export class Team {
|
||||
private deliver(name: string, text: string) {
|
||||
const a = this.agents.get(name)
|
||||
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 })
|
||||
if (a.state === "working") return this.o.effects?.deliver(name, text)
|
||||
a.inbox.push(text)
|
||||
@@ -345,10 +451,11 @@ export class Team {
|
||||
/** What was delivered, to whom, since this was last read (for a front end and the conformance). */
|
||||
delivered: { name: string; text: string }[] = []
|
||||
|
||||
private post(from: string, to: string[], recipients: string[], text: string, report: boolean): RoomMessage {
|
||||
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report }
|
||||
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, kind }
|
||||
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}`)
|
||||
return m
|
||||
}
|
||||
@@ -356,32 +463,49 @@ export class Team {
|
||||
// ── the tools ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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
|
||||
if (no) return no
|
||||
if (by !== LEAD) return TEXTS.only_main
|
||||
if (a.name === "all") return TEXTS.all_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 (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
|
||||
if (this.agents.has(a.name) || a.name === TEAM.names.parent) return fill(TEXTS.taken, { name: a.name })
|
||||
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 })
|
||||
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. */
|
||||
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)
|
||||
if (no) return { text: no }
|
||||
const lead = this.agents.get(LEAD)!
|
||||
const goal = a.goal?.trim()
|
||||
const caller = this.agents.get(by) ?? lead
|
||||
if (by === LEAD && goal) lead.goal = goal
|
||||
const parent = this.agents.get(a.parent ?? LEAD)!
|
||||
if (goal) lead.goal = goal
|
||||
const placed = checkMode(parent.mode, a.mode, a.request) as { mode: Mode; request: ToolClass[] }
|
||||
const member: TeamAgent = {
|
||||
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
|
||||
name: a.name,
|
||||
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,
|
||||
goal: goal || caller.goal,
|
||||
model: a.model ?? caller.model,
|
||||
goal: goal || parent.goal || lead.goal,
|
||||
model: a.model ?? lead.model,
|
||||
agent: a.agent ?? "general",
|
||||
state: "finished",
|
||||
inbox: [],
|
||||
@@ -399,7 +523,13 @@ export class Team {
|
||||
send(by: string, a: { to: string[] | string; message: string }): string {
|
||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
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 others = [...this.agents.keys()].filter((n) => n !== by)
|
||||
if (all) {
|
||||
@@ -452,12 +582,21 @@ export class Team {
|
||||
fill(R.agent, {
|
||||
indent: " ".repeat(depth(a)),
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
role: a.role,
|
||||
state: R.states[a.state],
|
||||
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` : "—",
|
||||
}),
|
||||
)
|
||||
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)
|
||||
lines.push("")
|
||||
if (!messages.length) lines.push(fill(R.none, { since }))
|
||||
@@ -477,6 +616,7 @@ export class Team {
|
||||
stopCmd(by: string, name: string): string {
|
||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
if (no) return no
|
||||
if (by !== LEAD) return TEXTS.only_main
|
||||
if (name === "all") {
|
||||
const under = this.membersOf(by)
|
||||
if (!under.length) return TEXTS.nobody_to_stop
|
||||
@@ -500,10 +640,26 @@ export class Team {
|
||||
return gone.map((a) => a.name)
|
||||
}
|
||||
|
||||
private remove(gone: TeamAgent[]) {
|
||||
private remove(gone: TeamAgent[], end: EndState = "stopped") {
|
||||
for (const a of gone) {
|
||||
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()
|
||||
}
|
||||
@@ -512,7 +668,16 @@ export class Team {
|
||||
* stop, steps, budget, or `error — <text>`. */
|
||||
finish(name: string, reply = "", reason = "stop") {
|
||||
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.ended = this.now()
|
||||
if (a.manager) {
|
||||
@@ -547,6 +712,241 @@ export class Team {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
||||
|
||||
@@ -11,7 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
|
||||
import { planUpdateTool } from "./plan_update.ts"
|
||||
import { planWriteTool } from "./plan_write.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 { webFetchTool, webSearchTool } from "./web.ts"
|
||||
import { memoryTool } from "./memory.ts"
|
||||
@@ -46,6 +46,14 @@ export const BUILTIN_TOOLS: Tool[] = [
|
||||
teamSendTool,
|
||||
teamReadTool,
|
||||
teamStopTool,
|
||||
// Spec 3.4.0 (subagents.md §8).
|
||||
teamWhoamiTool,
|
||||
teamFindingsTool,
|
||||
teamFinishTool,
|
||||
teamRequestTool,
|
||||
teamDecideTool,
|
||||
teamAssignTool,
|
||||
teamReplaceTool,
|
||||
planWriteTool,
|
||||
planUpdateTool,
|
||||
tasksTool,
|
||||
|
||||
+116
-2
@@ -1,5 +1,6 @@
|
||||
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the
|
||||
// room, read the team and the room, stop a member. The rules are team/team.ts; these are its calls
|
||||
// The team tools (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13; tools/team_*.json):
|
||||
// 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`).
|
||||
import { z } from "zod"
|
||||
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
||||
@@ -37,6 +38,9 @@ export const teamAddTool = defineTool({
|
||||
lead: z.boolean().optional(),
|
||||
agent: 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,
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
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 }
|
||||
},
|
||||
})
|
||||
@@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
const sendToJob = (text: string) => {
|
||||
const id = entered()!
|
||||
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.state === "running") {
|
||||
job.subagent.send(text)
|
||||
|
||||
+38
-4
@@ -12,6 +12,7 @@ import { instructionFiles } from "../prompt/assemble.ts"
|
||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.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 { git } from "../git/run.ts"
|
||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||
@@ -262,12 +263,29 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "team",
|
||||
args: "[size [<min> <max>] | stop [<name>]]",
|
||||
description: "subagents: the team and the room; how many; stop one",
|
||||
args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
|
||||
description: "subagents: the tree and the room; how many; stop one; their findings",
|
||||
run(c, arg) {
|
||||
// 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)
|
||||
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) {
|
||||
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
||||
if (!members.length) return c.view.info("the team has no members")
|
||||
@@ -708,8 +726,24 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "context",
|
||||
description: "where the context window goes",
|
||||
run(c) {
|
||||
args: "[<subagent>]",
|
||||
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
|
||||
// The prompt the session sends (made now if no request has gone yet).
|
||||
const system = e.systemPrompt()
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { createSignal, For, onCleanup } from "solid-js"
|
||||
import { duration } from "../../duration.ts"
|
||||
import { progressLine } from "../../session/progress.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import { fmtTokens } from "./status.tsx"
|
||||
|
||||
/** "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;
|
||||
* 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 {
|
||||
const pct = Math.floor((p.done / p.total) * 100)
|
||||
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
||||
@@ -34,6 +41,16 @@ export function Spinner() {
|
||||
}
|
||||
const what = () => {
|
||||
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.phase === "waiting" && s.progress) return reading(s.progress)
|
||||
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
||||
@@ -45,9 +62,9 @@ export function Spinner() {
|
||||
<box paddingLeft={1} height={1}>
|
||||
<text wrapMode="none" truncate>
|
||||
<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 }}>
|
||||
{elapsed()} · {tokens()} · esc to stop
|
||||
{view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
|
||||
const j = job()
|
||||
if (!j) return `subagent · ${p.id} · gone · esc back`
|
||||
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(" · ")
|
||||
}
|
||||
const what = () => job()?.description ?? ""
|
||||
|
||||
+6
-1
@@ -82,7 +82,7 @@ export interface Pending {
|
||||
/** What the model says the call is for. */
|
||||
purpose?: string
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ export interface ViewState {
|
||||
progress?: { total: number; done: number; processed: number; ms: number }
|
||||
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
||||
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
|
||||
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
|
||||
if (e.state === "start") startTask()
|
||||
else inTask = false
|
||||
break
|
||||
case "phase":
|
||||
set("now", e.progress ? { ...e.progress } : null)
|
||||
break
|
||||
case "sub_tool":
|
||||
set(
|
||||
"items",
|
||||
|
||||
+1
-1
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
|
||||
config(fake.url)
|
||||
const root = project()
|
||||
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")
|
||||
|
||||
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
||||
|
||||
+76
-6
@@ -28,9 +28,12 @@ import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Re
|
||||
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||
import { absPath, type ToolContext } from "../src/tool/tool.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 { 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>
|
||||
|
||||
@@ -257,9 +260,12 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
// 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: (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
|
||||
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 out: Record<string, string[]> = {}
|
||||
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
||||
@@ -270,8 +276,8 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
switch (st.op) {
|
||||
case "offered": {
|
||||
const a = team.get(by)!
|
||||
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
|
||||
return { tools: tools.sort(), setting: team.offered }
|
||||
const tools = !team.offered ? [] : by === "lead" ? [...LEAD_TOOLS] : memberTools(a)
|
||||
return { tools: [...tools].sort(), setting: team.offered }
|
||||
}
|
||||
case "call": {
|
||||
const args = st.args as Record<string, any>
|
||||
@@ -281,9 +287,18 @@ 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(", ") }))
|
||||
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_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 {
|
||||
result = team.refusal(by, st.tool, args) ?? null
|
||||
if (st.tool === "task" && result === null && team.offered) team.taskStart(by)
|
||||
@@ -310,10 +325,65 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
}
|
||||
case "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)}`)
|
||||
})
|
||||
},
|
||||
// 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),
|
||||
toolcall_index: (c) => {
|
||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ describe("end to end", () => {
|
||||
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
|
||||
// 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")
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
expect(TOOL_SPECS.todo).toBeUndefined()
|
||||
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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
+2
-2
@@ -26,7 +26,7 @@ const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||
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 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 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("")
|
||||
@@ -95,7 +95,7 @@ describe("a team at work (team.md)", () => {
|
||||
expect(t.get("parser")!.state).toBe("finished")
|
||||
expect(t.isManager("lead")).toBe(true)
|
||||
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)
|
||||
// A member is not a job to the model.
|
||||
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
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." })] }])
|
||||
await t.input.typeText("go")
|
||||
t.input.pressEnter()
|
||||
expect(await t.until("~15 tokens")).toBe(true)
|
||||
const frame = t.frame()
|
||||
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(await t.until("Done thinking.")).toBe(true)
|
||||
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
|
||||
// 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.
|
||||
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()
|
||||
expect(t.frame()).toMatch(counted)
|
||||
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")] }])
|
||||
await t.input.typeText("go")
|
||||
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)
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user