Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0fa62a51
|
||
|
|
f3db970c9d
|
||
|
|
ae1201540a
|
||
|
|
b70cfe483b
|
||
|
|
ecfd1b0b8a
|
||
|
|
8c5edc0f01
|
||
|
|
b66891e186
|
||
|
|
6199028358
|
||
|
|
4b8786b7f6
|
||
|
|
6a0e0ed145
|
||
|
|
3a206959b6
|
||
|
|
6993937522
|
||
|
|
f88921667e
|
||
|
|
0c3596d044
|
||
|
|
6e0582dc22
|
||
|
|
a26dd8a5e0
|
||
|
|
02c7282e74
|
||
|
|
9499e93bc4
|
No files matched your search
@@ -4,6 +4,93 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [2.0.0-beta.6] — 2026-11-10
|
||||||
|
|
||||||
|
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
|
||||||
|
subagents organised as the main agent's tree.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Progress phases.** The line under the conversation says what the agent is doing in the words
|
||||||
|
every LLeMbas client uses — *Queued*, *Waiting for {model}*, *Reading the prompt: n / total
|
||||||
|
tokens (p%)*, *Thinking*, *Writing: n tok/s*, *Running {tool}: {what}*, *Waiting for you*,
|
||||||
|
*Waiting for subagents*, *Compacting the conversation*, *Retrying in n s*, *Stopped*, *Failed* —
|
||||||
|
with how long it has lasted, then the turn's time and tokens. The CLI keeps its own estimate of
|
||||||
|
how long the rest of a long prompt will take. Over the link each phase is a `phase` event and is
|
||||||
|
in the session's status (capability `progress`).
|
||||||
|
- **The main agent and its subagents.** Only the main agent adds, reassigns (`team_assign`),
|
||||||
|
replaces (`team_replace`), stops and decides; a subagent added with `lead: true` is a
|
||||||
|
sub-manager that directs the subagents placed under it (`parent`) and adds none. Each subagent
|
||||||
|
has an id (`s1`, `s2`, …), a name, a position, a parent, a task, a mode and a model, and can ask
|
||||||
|
`team_whoami`. With Min above 1 the main agent starts with Min − 1 open slots to fill.
|
||||||
|
- **Permissions only go down.** A subagent's mode is its parent's or stricter. A narrowed one may
|
||||||
|
be given request options (read, write, execute) and ask the main agent with `team_request`; the
|
||||||
|
main agent decides with `team_decide`, and never allows more than its own mode would. What
|
||||||
|
nobody in the tree could allow — a `bash` call in an all-Edit tree — is put to you, on an
|
||||||
|
approval card that names the subagent.
|
||||||
|
- **A subagent's end.** It reports and ends with `team_finish`. Its findings (`team_findings`) are
|
||||||
|
kept after it ends. When its context fills it does not compact: it is paused, gets one last
|
||||||
|
turn of at most 8 steps to save its findings and tell its parent, and ends; the main agent
|
||||||
|
replaces it or uses what it found.
|
||||||
|
- **Seeing the tree.** `/team` draws every agent with its position, state, model, mode, context
|
||||||
|
use, current phase and messages, the open slots and the subagents that ended; `/team open
|
||||||
|
<name>` shows a subagent's session read-only, live or after it ended; `/team findings <name>`;
|
||||||
|
`/context <name>` for a subagent's context.
|
||||||
|
- **An agent chat's subagents run on this device.** The chat's Min and Max come with every turn
|
||||||
|
(capability `agents`) and the tree, progress, findings and transcripts go back to the server
|
||||||
|
(capability `tree`: the `team` event with the tree, `_lembas/team`, `/transcript`, `/findings`,
|
||||||
|
`/stop`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A subagent can no longer add subagents of its own or start a `task`; it asks the main agent.
|
||||||
|
`team_stop` is the main agent's alone.
|
||||||
|
|
||||||
|
## [2.0.0-beta.5] — 2026-11-09
|
||||||
|
|
||||||
|
Harness spec 3.3.0 (spec commit 02c7282): subagents run on your model unless you name another,
|
||||||
|
and `/context` shows where the window goes the way the side panel of the other apps does.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subagents run on the session's model**, unless you named another model in this session's own
|
||||||
|
messages — its id, the part after its provider, or its display name, as a whole word, in any
|
||||||
|
letter case. A `task` or `team_add` that asks for any other model is refused ("Use another
|
||||||
|
model for subagents only when the person names it."), and an agent file's own `model:` is no
|
||||||
|
longer used. Nothing in the settings chooses a subagent's model.
|
||||||
|
- **Only the logical capacity limits remain.** A named model on the same one-model server as the
|
||||||
|
session's model is refused ("That model is on the same one-model server as this chat's model;
|
||||||
|
name a model on another server."). On a model that serves one request at a time, a subagent on
|
||||||
|
that same model is refused ("This model serves one request at a time: name another model, on
|
||||||
|
another server, for subagents.") and nothing else is picked instead.
|
||||||
|
- **Subagents are offered** — the size setting, the team tools — when subagents are on and some
|
||||||
|
model could serve one: the session's, when it serves two requests at once, or any model of the
|
||||||
|
server that is not on its one-model server. When the session's model cannot serve its own
|
||||||
|
subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it
|
||||||
|
beside the setting.
|
||||||
|
- **"Subagents" is the word** for the setting, with **Min** and **Max**: the row beside the effort
|
||||||
|
in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status bar reads
|
||||||
|
`agents 3/4 · 2 subagents working` while there is a team.
|
||||||
|
- **The numbers count this agent**: `Subagents: Min 1, Max 4 — 1 is this agent alone`. Max 1 is
|
||||||
|
the agent alone, with no subagents; Max 4 is the agent and up to three subagents.
|
||||||
|
- **The setting is one range with two handles.** In `/effort` and `/team size` the Subagents row
|
||||||
|
shows `Min 1`, a track from 1 to the cap with a handle for each, and `Max 4`: ←→ move the handle in focus, tab switches to the other, enter
|
||||||
|
sets both for the session (Min never passes Max; Max goes up to `agents.cap`). `/team size` also
|
||||||
|
keeps the usual pairs and `<min> <max>` typed.
|
||||||
|
- **`/context`** shows the window as used tokens of the window and a percentage, one bar split by
|
||||||
|
category, every category with its tokens and its share of the window, what is free, and how far
|
||||||
|
it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now).
|
||||||
|
|
||||||
|
## [2.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`lembas login` no longer loops on a sign-in the server stopped accepting.** With an earlier login
|
||||||
|
for the same server kept here (from an older install, or LLeMbas Desktop), login only refreshed it;
|
||||||
|
when the server refused the old token it said “run login again”, and running it again did the same.
|
||||||
|
Now a refused sign-in starts a new one, which replaces the old.
|
||||||
|
- The installer's last line says to run `lembas login`, not to edit `connections.yaml` (gone in 2.0).
|
||||||
|
|
||||||
## [2.0.0-beta.3] — 2026-11-09
|
## [2.0.0-beta.3] — 2026-11-09
|
||||||
|
|
||||||
Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent
|
Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent
|
||||||
|
|||||||
+70
-5
@@ -15,17 +15,19 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `VERSION` | The spec's own version, apart from LLeMbas CLI's |
|
| `VERSION` | The spec's own version, apart from LLeMbas CLI's |
|
||||||
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
|
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
|
||||||
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
|
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`, `team_finish`, `team_request`; `main`, spec 3.4.0: given to the main agent only, never to a subagent — `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
|
||||||
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
|
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
|
||||||
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
|
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
|
||||||
| `permission/arity.json` | How much of a command an "always allow" covers |
|
| `permission/arity.json` | How much of a command an "always allow" covers |
|
||||||
| `permission/defaults.json` | The rules every session starts from |
|
| `permission/defaults.json` | The rules every session starts from |
|
||||||
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
||||||
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
|
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
|
||||||
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `team` — a team's size, names, marks, refusals and texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
|
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
|
||||||
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
||||||
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
|
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
|
||||||
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
|
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
|
||||||
|
| `subagents.md` | Subagents: the word, which model one runs on (the session's, or one the person named), the capacity refusals, and when the Subagents setting is offered; from 3.4.0 (§5–§13) the main agent and its tree — only it adds, permissions inherited and never widened, request options, a subagent's life and its findings, what clients show |
|
||||||
|
| `progress.md` | Progress (3.4.0): the thirteen phases every client shows the same way, the data each carries, their texts, and where they are sent |
|
||||||
| `quirks.json` | What real servers do, and how both harnesses cope |
|
| `quirks.json` | What real servers do, and how both harnesses cope |
|
||||||
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
||||||
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
|
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
|
||||||
@@ -33,7 +35,7 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
- **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one
|
- **Risk classes:** `read`, `write`, `execute`, `interact`. **Reading and writing are never one
|
||||||
tool** among the shared ones, so a read-only helper is given exactly the readers.
|
tool** among the shared ones, so a read-only subagent is given exactly the readers.
|
||||||
- **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`).
|
- **Modes:** `manual`, `edit`, `auto`, `plan` (`unrestricted` is read as `auto`).
|
||||||
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
|
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
|
||||||
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
|
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
|
||||||
@@ -48,8 +50,9 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
page of the chat is open and attached (not a schedule, not a background job); `display`: the
|
page of the chat is open and attached (not a schedule, not a background job); `display`: the
|
||||||
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
|
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
|
||||||
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
|
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
|
||||||
plan mode; `team` (3.2.0): the session's team setting allows more than one agent, on a model that
|
plan mode; `team` (3.2.0): the session's team setting allows more than one agent, and a model could
|
||||||
serves two requests at once (`team.md` §2). A requirement that goes away between the offer and the call makes the call answer the
|
serve as a subagent (3.3.0: `subagents.md` §4; before, the chat's model serving two requests at
|
||||||
|
once). A requirement that goes away between the offer and the call makes the call answer the
|
||||||
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
|
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
|
||||||
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
|
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
|
||||||
tool is offered from the start and answers `unavailable` while the requirement is not met.
|
tool is offered from the start and answers `unavailable` while the requirement is not met.
|
||||||
@@ -68,6 +71,68 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
||||||
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
||||||
|
|
||||||
|
## 3.4.0
|
||||||
|
|
||||||
|
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
||||||
|
round (`progress.md`, `subagents.md` §5–§13).
|
||||||
|
|
||||||
|
- **Progress** (`progress.md`, `loop.json` `progress`): one fixed set of phases — `queued`,
|
||||||
|
`waiting_for_model`, `loading_model`, `reading_prompt`, `thinking`, `writing`, `running_tool`,
|
||||||
|
`waiting_for_person`, `waiting_for_subagents`, `compacting`, `retrying`, `stopped`, `failed` —
|
||||||
|
each a structured object `{ phase, since, …data }` with fixed fields, an English text that is
|
||||||
|
the shared i18n key, and one line every client draws: `text · elapsed`. Sent beside the old
|
||||||
|
`status` text in `/api/v2` and over the link (`acp.md` capability `progress`: the `phase` event,
|
||||||
|
`progress` in `status`); `status` stays. Area `progress`.
|
||||||
|
- **Subagents v2** (`subagents.md` §5–§13; supersedes `team.md` where they differ, which says so):
|
||||||
|
the **main agent** is the session's, permanent, and **alone adds, assigns, replaces, stops and
|
||||||
|
decides** (`offered: main`); a member with `lead: true` is a **sub-manager** that directs those
|
||||||
|
placed under it (`team_add`'s `parent`). Every subagent has an id (`s1`…), a name, a position, a
|
||||||
|
parent, a task, a mode and a model. Min above 1 opens `min − 1` **slots** from the start of the
|
||||||
|
reply. **Permissions are inherited and never widened**: a subagent's mode is its parent's or
|
||||||
|
stricter; a narrowed one may get **request options** (`read`, `write`, `execute`) and ask the
|
||||||
|
main agent (`team_request` → `team_decide`, a grant that never reaches past the main agent's own
|
||||||
|
mode); a call nobody in the tree can allow goes **to the person**. A subagent ends with
|
||||||
|
`team_finish`, is stopped, or **runs out of context**: paused, one final turn to save its
|
||||||
|
findings and tell its parent, then `out_of_context` and a report; findings and transcripts are
|
||||||
|
kept, readable by the parent, the main agent and the person. New tools `team_whoami`,
|
||||||
|
`team_finish`, `team_findings`, `team_request`, `team_decide`, `team_assign`, `team_replace`;
|
||||||
|
`team_add` gains `parent`, `mode`, `request`; `team_send` takes `parent`; `team_stop` is the main
|
||||||
|
agent's. `blocks/team_submanager.md`; `team_lead.md` and `team_member.md` reworded. Over the link,
|
||||||
|
capability `tree`: the `team` event, `_lembas/team`, `/transcript` (read-only), `/findings`,
|
||||||
|
`/stop`, `subagent` on an approval. **An agent chat's subagents are the device's** (`subagents.md`
|
||||||
|
§14, `acp.md` *Subagents in an agent chat*): capability `agents` carries the chat's `{ min, max }`
|
||||||
|
down on every turn (and `said`, the person's earlier words, on `session/new`); the tree, progress,
|
||||||
|
approvals, findings and transcripts come up. Areas `team` (cases changed where a member used to add or
|
||||||
|
stop, and new ones) and `delegation`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
Written ahead of both implementations as LLeMbas CLI 02c7282; both carry it in the same round (`subagents.md`).
|
||||||
|
|
||||||
|
- **Subagent** is the word everywhere a person or a model reads one — a `task` subagent and a
|
||||||
|
team's member alike; *helper* is retired. Tool names and aliases are unchanged. The `team`
|
||||||
|
conformance area's step `helper_done` is now **`task_done`**.
|
||||||
|
- **The model**: a subagent runs on the session's model, unless the person named another one in
|
||||||
|
this session's own messages (its served id, its id at its connection or its display name, a whole
|
||||||
|
word, without case — never the model's text or a tool's output). `task`'s and `team_add`'s
|
||||||
|
`model` is refused otherwise: "Use another model for subagents only when the person names it."
|
||||||
|
No setting chooses a subagent's model; an agent definition's own `model` is not used for one.
|
||||||
|
- **Capacity, the logical restrictions only**: another model on the session's model's one-model
|
||||||
|
server is refused ("That model is on the same one-model server as this chat's model; name a
|
||||||
|
model on another server."); the session's model as its own subagent needs `parallel ≥ 2` or no
|
||||||
|
limit, else "This model serves one request at a time: name another model, on another server,
|
||||||
|
for subagents." — and nothing else is picked. A member is refused for these too, and still only
|
||||||
|
waits for a full model.
|
||||||
|
- **The offer**: the `agents {min, max}` setting is shown as **Subagents** (**Min**, **Max**;
|
||||||
|
`loop.json` `subagents.setting`) — one range from 1 to the cap with two handles, and a hint that
|
||||||
|
the numbers count the session's own agent: 1 is the agent alone, Max 4 the agent and up to three
|
||||||
|
subagents — and with it the team tools, when subagents are enabled and at
|
||||||
|
least one model could serve: the session's, with the capacity, or any usable model off its
|
||||||
|
one-model server. `context.note.team_own` tells the model, and `subagents.setting.own` the
|
||||||
|
person, when the session's model cannot serve subagents itself.
|
||||||
|
- `task`'s and `team_add`'s `model` descriptions, `task`'s description (a line on the model),
|
||||||
|
`models/schema.json` capacity texts, `team.md` §2, §4, §8. Conformance area **`subagents`**.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
Written ahead of both implementations; both carry it in the same round.
|
Written ahead of both implementations; both carry it in the same round.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.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` |
|
| `rewind` | `_lembas/session/rewind` |
|
||||||
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
|
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
|
||||||
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
|
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
|
||||||
|
| `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` |
|
||||||
|
| `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) |
|
||||||
|
| `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` |
|
||||||
|
|
||||||
## Standard ACP, as used here
|
## Standard ACP, as used here
|
||||||
|
|
||||||
@@ -245,11 +248,64 @@ with options `once` (allow_once), `session` (allow_always, absent where "always"
|
|||||||
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a
|
and `deny` (reject_once), and `_meta.lembas: { reason, purpose?, always?, patient?, job? }`. `patient`: a
|
||||||
person may also answer at the terminal (a shared session), so the client does not time it out.
|
person may also answer at the terminal (a shared session), so the client does not time it out.
|
||||||
`job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card
|
`job` (capability `jobs`): `{ id, agent, description }` when a subagent running as a background job asks, so the card
|
||||||
can say which — a background subagent may ask while the session itself is idle.
|
can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0):
|
||||||
|
`{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) —
|
||||||
|
the card names it; the answers are the same as for the main agent's own call.
|
||||||
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
||||||
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
||||||
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
||||||
|
|
||||||
|
## Subagents in an agent chat (spec 3.4.0)
|
||||||
|
|
||||||
|
**An agent chat's main agent is the CLI on the device.** Its subagents are the device's: it starts
|
||||||
|
them locally, on the models of the server it is logged in to, under every rule of `subagents.md` —
|
||||||
|
the model rules (§2–§3), Min/Max, slots, permissions only ever narrowed, request options, and a
|
||||||
|
call nobody in the tree can allow going to the person. The server runs no subagent of its own for
|
||||||
|
an agent chat and offers its own subagent tool to no agent chat; what it does is carry the setting
|
||||||
|
down and the tree up. A device without the capabilities `agents` and `tree` gets neither and runs no
|
||||||
|
subagents for the chat (the chat says so beside the setting).
|
||||||
|
|
||||||
|
**What goes down, with every turn.**
|
||||||
|
|
||||||
|
- `agents: { min, max }` in `_meta.lembas` of `session/new`, of **every** `session/prompt`, and of
|
||||||
|
`_lembas/configure` when the person changes it between turns — the chat's setting as the server
|
||||||
|
keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the
|
||||||
|
session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and
|
||||||
|
nothing else reads the device's own configuration for the chat. Absent (an older server, or a
|
||||||
|
client that is not LLeMbas): the device's own setting, as before 3.4.0. A value the device's own global cap is under is read as `team.md` §2
|
||||||
|
says (lowered, never refused).
|
||||||
|
- `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the
|
||||||
|
device offers none whatever `agents` says.
|
||||||
|
- **What the person named** (`subagents.md` §2): the person's words reach the device as the turns'
|
||||||
|
prompt texts, so the device judges a named model from them as in its own sessions — every
|
||||||
|
`session/prompt` without `machine`; never a `machine` turn, a `_lembas/steer` from the harness, or
|
||||||
|
a tool's output. A chat continued on a device that has not seen its earlier turns gets them in
|
||||||
|
`_meta.lembas.said: string[]`, the person's earlier messages of the chat, oldest first (on
|
||||||
|
`session/new` only); the device counts them as typed in the session.
|
||||||
|
- The models: the device's subagents may run on any model of the person's (`/v1/models` as the
|
||||||
|
device's token sees it), as in its own sessions.
|
||||||
|
|
||||||
|
**What comes up.**
|
||||||
|
|
||||||
|
- The tree, as it changes: the `team` event (`{ tree }`, `subagents.md` §12's data, at most four a
|
||||||
|
second), and on request `_lembas/team` (the tree and the whole room). Every agent's `progress`
|
||||||
|
is in it; the main agent's own also as the `phase` event (capability `progress`).
|
||||||
|
- Approvals: a subagent's call that only the person can allow is an ordinary
|
||||||
|
`session/request_permission` with `_meta.lembas.subagent: { id, name, position }`; the server
|
||||||
|
shows it as its usual approval card naming the subagent, in every client. Requests the main agent
|
||||||
|
decides (`team_request` / `team_decide`) never reach the server as approvals: they are room
|
||||||
|
messages.
|
||||||
|
- Findings and transcripts on request: `_lembas/team/findings`, `_lembas/team/transcript` (live and
|
||||||
|
ended subagents, read-only — the link has no method that writes into a subagent's session).
|
||||||
|
- The person stopping a subagent: `_lembas/team/stop`.
|
||||||
|
|
||||||
|
**What the server keeps.** The latest tree with the chat (so a client opening the chat draws it at
|
||||||
|
once, and after the device has gone), and the room as `_lembas/team` last answered. Findings and
|
||||||
|
transcripts stay on the device; a client asking for one while the device is not linked is told it
|
||||||
|
is on the device, and gets it once the device links again. Over `/api/v2` the chat's stream carries
|
||||||
|
the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for
|
||||||
|
a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike.
|
||||||
|
|
||||||
## `_lembas/*` methods
|
## `_lembas/*` methods
|
||||||
|
|
||||||
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
||||||
@@ -275,7 +331,7 @@ client hears the new limits.
|
|||||||
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
`{ sessionId, event }`: every engine event of the session, to a client that said `_meta.lembas`.
|
||||||
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
`event.type` is one of `session`, `prompt`, `step`, `title`, `text`, `reasoning`,
|
||||||
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
|
`tool_call_delta`, `progress`, `tool_start`, `tool_end`, `tool_denied`, `usage`, `inbox`,
|
||||||
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `mode`, `model`, `flash`,
|
`steered`, `task`, `notice`, `retract`, `plan`, `compacted`, `sub_tool`, `job`, `job_note`, `phase`, `team`, `mode`, `model`, `flash`,
|
||||||
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
|
`setting`, `error`, `done` (`src/bus/index.ts`). Those this protocol relies on:
|
||||||
|
|
||||||
| Event | Fields |
|
| Event | Fields |
|
||||||
@@ -291,6 +347,8 @@ client hears the new limits.
|
|||||||
| `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended |
|
| `job` | `state: "start" \| "update" \| "end"`, `job: JobInfo` (capability `jobs`) — a background job of the session began, has news (new output, a subagent's step; at most four a second per job), or ended. `JobInfo = { id, kind: "command" \| "subagent", state: "running" \| "done" \| "stopped" \| "error" \| "lost", description, command?, agent?, model?, started, ended?, exit?, unread, steps?, tools?, ownerJobId? }` — `description` what is shown for it (a command's `description` or its first line; a subagent's description), `unread` characters of output the model has not read, `exit` a command's exit code (null: a signal), `ownerJobId` set on a job a subagent started, `lost` a job that was running when the device's process ended |
|
||||||
| `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows |
|
| `job_note` | `jobId`, `text` (capability `jobs`) — the note about that job was given to the model: inside a turn at the next step, or as the `machine` turn whose `prompt` follows |
|
||||||
| `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel |
|
| `sub_tool` | `callId` (the `task` call the subagent belongs to), `jobId?` (its job, when it runs as one), `name`, `title`, `isError?` — a tool call of a subagent finished; a client draws it under the task line, or in the job's panel |
|
||||||
|
| `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged |
|
||||||
|
| `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so |
|
||||||
|
|
||||||
### `_lembas/directories` — client → agent → result
|
### `_lembas/directories` — client → agent → result
|
||||||
|
|
||||||
@@ -301,7 +359,7 @@ Only on the link (an error on `serve --stdio`).
|
|||||||
|
|
||||||
### `_lembas/configure` — client → agent → result
|
### `_lembas/configure` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId, model?: <instance's name>, effort?: <level> }` between prompts (a level as in `session/new`). Result
|
Params `{ sessionId, model?: <instance's name>, effort?: <level>, agents?: { min, max } }` between prompts (a level as in `session/new`; `agents` with capability `agents`, spec 3.4.0). Result
|
||||||
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
`{ model: <ref>, effort }`. Emits a `model` event when something changed.
|
||||||
|
|
||||||
### `_lembas/steer` — client → agent → result
|
### `_lembas/steer` — client → agent → result
|
||||||
@@ -319,9 +377,9 @@ with the message in the store; `attachments`, and the text without them) or `{ r
|
|||||||
### `_lembas/session/status` — client → agent → result
|
### `_lembas/session/status` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId }`. Result
|
Params `{ sessionId }`. Result
|
||||||
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs? }`
|
`{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId?, plan?, jobs?, progress?, tree? }`
|
||||||
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
|
— `plan` the session's plan or `null` (capability `plan_state`), so a client reattaching draws it
|
||||||
without waiting for the next change; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last
|
without waiting for the next change; `progress` the main agent's progress object or `null` (capability `progress`), and `tree` the subagent tree or `null` when the session has none (capability `tree`), for the same reason; `jobs` the session's background jobs, `JobInfo[]` running first (capability `jobs`), for the same reason; the turn fields with capability `turns`: the turn running now, the ones waiting, the last
|
||||||
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
|
one finished (for a session not open, `held: "none"`, the last one it finished before). **Reattaching** after a restart or a dropped link, for an incomplete row with turn T:
|
||||||
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
|
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
|
||||||
`history`; T unknown → send the prompt again (safe: the device deduplicates).
|
`history`; T unknown → send the prompt again (safe: the device deduplicates).
|
||||||
@@ -362,6 +420,30 @@ with its output — so a client can show the subagent as a panel and follow it w
|
|||||||
`job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as
|
`job` events that come after. `-32602` for a command job. A subagent's transcript lasts as long as
|
||||||
its session row on the device: it is kept with the parent session and deleted with it.
|
its session row on the device: it is kept with the parent session and deleted with it.
|
||||||
|
|
||||||
|
### `_lembas/team` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, since?: number }`. Result `{ tree, room }`: the subagent tree (`subagents.md`
|
||||||
|
§12) and the room's messages numbered above `since` (default 0), every one — the person sees the
|
||||||
|
whole room — each `{ n, from, to, recipients, text, time, report, kind }` with `kind` one of
|
||||||
|
`message`, `report`, `assignment`, `request`, `decision`. `tree` is `null` for a session with no
|
||||||
|
subagents. For a session held by a terminal, answered through the hub, as `status` is.
|
||||||
|
|
||||||
|
### `_lembas/team/transcript` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }` — a subagent's id (`s1`), live or ended. Result `{ id, state, turns }` in
|
||||||
|
the shape of `_lembas/session/history`'s `turns`: its conversation so far, **read-only** — the link
|
||||||
|
has no method to write into it. A client following it live refetches on the `team` event and draws
|
||||||
|
the `sub_tool` events meanwhile. `-32602` for an id the session never had.
|
||||||
|
|
||||||
|
### `_lembas/team/findings` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }`. Result `{ id, findings: [{ n, text, time }] }`, ended subagents' too.
|
||||||
|
|
||||||
|
### `_lembas/team/stop` — client → agent → result (capability `tree`)
|
||||||
|
|
||||||
|
Params `{ sessionId, id }`: the person stops a subagent and its subtree (`subagents.md` §10), said to
|
||||||
|
the main agent as a note. Result `{ stopped: [id, …] }`; `-32602` for an id that is not live.
|
||||||
|
|
||||||
### `_lembas/session/delete` — client → agent → result
|
### `_lembas/session/delete` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
Params `{ sessionId }`: the chat was deleted. The session stops if it works, leaves the store (its
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+1672
-46
File diff suppressed because it is too large.
Load diff
+230
-15
@@ -34,7 +34,7 @@
|
|||||||
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
|
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
|
||||||
"max": 4
|
"max": 4
|
||||||
},
|
},
|
||||||
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). A team's members are not refused there: they wait for a slot (team.md, Capacity).",
|
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). Spec 3.3.0: which model a subagent may run on at all, and the refusals for the session's own one-model server and a one-request model, are subagents.md and `subagents` below, checked first. A team's members are not refused for a full model: they wait for a slot (team.md, Capacity).",
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
|
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -61,8 +61,30 @@
|
|||||||
},
|
},
|
||||||
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
|
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
|
||||||
},
|
},
|
||||||
|
"subagents": {
|
||||||
|
"about": "Spec 3.3.0 (subagents.md): which model a subagent — a task subagent or a team member — runs on, and when subagents are offered. The session's own model unless the person named another in this session; no setting chooses one. The texts are said word for word, checked in this order: not_named, same_group, one_request. conformance/subagents.json.",
|
||||||
|
"texts": {
|
||||||
|
"not_named": "Use another model for subagents only when the person names it.",
|
||||||
|
"same_group": "That model is on the same one-model server as this chat's model; name a model on another server.",
|
||||||
|
"one_request": "This model serves one request at a time: name another model, on another server, for subagents."
|
||||||
|
},
|
||||||
|
"named": {
|
||||||
|
"about": "A model is named when its served id, its id at its connection or its display name occurs as a whole word, without case, in a message the person typed in this session (never model text, tool output, files, notes, marked messages or reports; a compacted message still counts). Before the name: not a letter, digit, _, -, /, . or :. After it: not a letter, digit, _, - or /, nor a . or : followed by a letter or digit. White space in a display name matches any run of white space.",
|
||||||
|
"before": "[\\p{L}\\p{N}_\\-/.:]",
|
||||||
|
"after": "[\\p{L}\\p{N}_\\-/]|[.:][\\p{L}\\p{N}]"
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"about": "How the agents {min, max} setting (team.md §2) is shown to a person: one range from 1 to the cap with two handles, `min` and `max` naming them; `label` beside it, `hint` (or `short` where one line has no room) saying what the numbers count — the session's own agent included, so 1 is that agent alone and Max 4 is it and up to 3 subagents; and `own` beside it when the session's model cannot serve subagents itself (subagents.md §4).",
|
||||||
|
"label": "Subagents",
|
||||||
|
"min": "Min",
|
||||||
|
"max": "Max",
|
||||||
|
"hint": "The count includes this agent: 1 is this agent alone, with no subagents; Max 4 is this agent and up to 3 subagents.",
|
||||||
|
"short": "1 is this agent alone",
|
||||||
|
"own": "This model serves one request at a time: subagents need another model, on another server, named in the chat."
|
||||||
|
}
|
||||||
|
},
|
||||||
"team": {
|
"team": {
|
||||||
"about": "Teams (spec 3.2.0, team.md): a lead and the members it adds, who talk in the team's room. The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json.",
|
"about": "Teams (spec 3.2.0, team.md; which model a member runs on and when a team is offered: spec 3.3.0, subagents.md; the main agent, the tree, permissions and a subagent's life: spec 3.4.0, subagents.md §5–§13). The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json, conformance/delegation.json.",
|
||||||
"agents": {
|
"agents": {
|
||||||
"about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).",
|
"about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).",
|
||||||
"min": 1,
|
"min": 1,
|
||||||
@@ -70,20 +92,27 @@
|
|||||||
"cap": 32
|
"cap": 32
|
||||||
},
|
},
|
||||||
"names": {
|
"names": {
|
||||||
"about": "A member's name: unique in the team; `lead` is the lead's, `all` addresses everyone.",
|
"about": "A subagent's name: unique among the subagents that have not ended; `lead` is the main agent's, `all` addresses everyone, `parent` (3.4.0) the sender's parent.",
|
||||||
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||||
"lead": "lead",
|
"lead": "lead",
|
||||||
"reserved": [
|
"reserved": [
|
||||||
"all",
|
"all",
|
||||||
"lead"
|
"lead",
|
||||||
]
|
"parent"
|
||||||
|
],
|
||||||
|
"parent": "parent",
|
||||||
|
"id_prefix": "s"
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
"message": "[Team message from {{from}} to {{to}}:]",
|
"message": "[Team message from {{from}} to {{to}}:]",
|
||||||
"report": "[Team report from {{from}} to {{to}}:]"
|
"report": "[Team report from {{from}} to {{to}}:]",
|
||||||
|
"assignment": "[Team assignment from {{from}} to {{to}}:]",
|
||||||
|
"request": "[Team request {{id}} from {{from}} to {{to}}:]",
|
||||||
|
"decision": "[Team decision on {{id}} from {{from}} to {{to}}:]",
|
||||||
|
"context_full": "[Team notice to {{to}}: your context is full]"
|
||||||
},
|
},
|
||||||
"unavailable": {
|
"unavailable": {
|
||||||
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member.",
|
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member. one_request: no model can serve as a subagent any more (subagents.md §4) — the session's model's capacity changed after the prompt was made.",
|
||||||
"one_agent": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
"one_agent": "The person allows one agent in this chat: there is no team. Do the work yourself.",
|
||||||
"one_request": "This model serves one request at a time now, so there is no team. Do the work yourself."
|
"one_request": "This model serves one request at a time now, so there is no team. Do the work yourself."
|
||||||
},
|
},
|
||||||
@@ -131,25 +160,106 @@
|
|||||||
"not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.",
|
"not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.",
|
||||||
"nobody_to_stop": "No member is under you: there is nobody to stop.",
|
"nobody_to_stop": "No member is under you: there is nobody to stop.",
|
||||||
"no_report": "(no report)",
|
"no_report": "(no report)",
|
||||||
"ended": "[ended: {{reason}}]"
|
"ended": "[ended: {{reason}}]",
|
||||||
|
"only_main": "Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).",
|
||||||
|
"not_manager": "{{name}} is not a sub-manager: place a subagent under the main agent (lead) or under one added with lead: true.",
|
||||||
|
"wider": "A subagent's mode cannot be looser than its parent's ({{parent_mode}}).",
|
||||||
|
"request_not_narrower": "Request options go with a mode stricter than the parent's: without one there is nothing to ask for.",
|
||||||
|
"request_kind": "{{kind}} is not a request option: use read, write or execute.",
|
||||||
|
"requestable": "Your mode does not allow this, but the main agent may: ask for it with team_request (the tool, the pattern, why), then try again once it allows.",
|
||||||
|
"not_delegated": "Your mode does not allow this, and you may not ask for it: tell your parent what you need.",
|
||||||
|
"not_requestable": "You may not ask for {{tool}}: your request options are {{kinds}}.",
|
||||||
|
"requested": "Asked the main agent ({{id}}): you are told its decision. Carry on with what you can meanwhile.",
|
||||||
|
"no_request": "No open request {{id}}.",
|
||||||
|
"decided": "{{decision}} {{id}} for {{name}}.",
|
||||||
|
"allowed": "Allowed",
|
||||||
|
"refused": "Refused",
|
||||||
|
"decision_allowed": "Allowed: {{tool}} {{pattern}}. Try it again now.",
|
||||||
|
"decision_refused": "Refused: {{tool}} {{pattern}}.",
|
||||||
|
"no_parent": "You are the main agent: you have no parent.",
|
||||||
|
"finished": "Reported to {{to}}. Your task is done: stop here.",
|
||||||
|
"has_ended": "{{name}} has ended: replace it (team_replace) or add another.",
|
||||||
|
"cycle": "{{name}} cannot go under {{parent}}: that is under it.",
|
||||||
|
"assigned": "Assigned to {{name}}: it is told now.",
|
||||||
|
"replaced": "Replaced {{name}}: {{id}} takes its place, briefed with its task and findings. The team is {{size}} of {{max}} agents.",
|
||||||
|
"replace_brief": "{{task}}\n\nYou take over from {{name}} ({{id}}), which ended. What it found:\n{{findings}}",
|
||||||
|
"no_findings": "(no findings)",
|
||||||
|
"noted": "Noted (finding {{n}}).",
|
||||||
|
"not_yours_findings": "You may read only your own findings and those of the subagents under you.",
|
||||||
|
"context_full": "Save what you found with team_findings, then tell your parent with team_send that you are gone and what is left undone. This is your last turn: at most {{steps}} steps.",
|
||||||
|
"gone": "{{name}} ({{id}}) ran out of context and has ended. Its findings: team_findings with of {{name}}.",
|
||||||
|
"request_body": "{{tool}} {{pattern}} — {{why}}"
|
||||||
},
|
},
|
||||||
"read": {
|
"read": {
|
||||||
"about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown.",
|
"about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown. Spec 3.4.0: the row adds the id, the mode (with request options) and the context use; open slots follow the tree, ended subagents after them.",
|
||||||
"tree": "Team ({{size}} of {{max}} agents):",
|
"tree": "Team ({{size}} of {{max}} agents):",
|
||||||
"agent": "{{indent}}- {{name}} — {{role}} · {{state}} · {{model}} · {{last}}",
|
"agent": "{{indent}}- {{name}} ({{id}}) — {{role}} · {{state}} · {{model}} · {{mode}} · {{context}} · {{last}}",
|
||||||
"lead_role": "leads the team",
|
"lead_role": "the main agent",
|
||||||
"states": {
|
"states": {
|
||||||
"working": "working",
|
"working": "working",
|
||||||
"waiting": "waiting for a slot",
|
"waiting": "waiting for a slot",
|
||||||
"finished": "finished",
|
"finished": "finished",
|
||||||
"idle": "idle"
|
"idle": "idle",
|
||||||
|
"paused": "paused: context full",
|
||||||
|
"done": "done",
|
||||||
|
"out_of_context": "out of context",
|
||||||
|
"stopped": "stopped"
|
||||||
},
|
},
|
||||||
"active": "active now",
|
"active": "active now",
|
||||||
"room": "Room since #{{since}}:",
|
"room": "Room since #{{since}}:",
|
||||||
"message": "#{{n}} {{from}} → {{to}}: {{text}}",
|
"message": "#{{n}} {{from}} → {{to}}: {{text}}",
|
||||||
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
|
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
|
||||||
"none": "No messages since #{{since}}.",
|
"none": "No messages since #{{since}}.",
|
||||||
"more": "More after #{{last}}: team_read with since {{last}}."
|
"more": "More after #{{last}}: team_read with since {{last}}.",
|
||||||
|
"slots": "Open slots: {{n}} — fill them with team_add.",
|
||||||
|
"ended": "Ended:",
|
||||||
|
"ended_agent": "- {{name}} ({{id}}) — {{role}} · {{state}} · {{findings}} findings",
|
||||||
|
"context": "{{percent}}% context",
|
||||||
|
"request": "+ {{kinds}}"
|
||||||
|
},
|
||||||
|
"main_only": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §6, §8): the tools only the main agent has; a subagent's call to one, or to task, answers texts.only_main.",
|
||||||
|
"tools": [
|
||||||
|
"team_add",
|
||||||
|
"team_assign",
|
||||||
|
"team_decide",
|
||||||
|
"team_replace",
|
||||||
|
"team_stop"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §9): a subagent does not compact. When its context reaches pause_at of its model's window it is paused and given one final turn of at most final_steps steps, whose user message is the context_full mark and texts.context_full.",
|
||||||
|
"pause_at": 0.85,
|
||||||
|
"final_steps": 8
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §7): the kinds a request option may name — risk classes — and the ids of requests, r1, r2, … per session.",
|
||||||
|
"kinds": [
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"id_prefix": "r"
|
||||||
|
},
|
||||||
|
"whoami": {
|
||||||
|
"about": "Spec 3.4.0 (subagents.md §8): team_whoami's answer, one line each; `request` only with request options, `parent` only for a subagent. {{percent}} is floor(used × 100 / window).",
|
||||||
|
"lines": {
|
||||||
|
"who": "You are {{name}} ({{id}}): {{role}}.",
|
||||||
|
"parent": "Your parent: {{parent}}.",
|
||||||
|
"task": "Your task: {{task}}",
|
||||||
|
"mode": "Your mode: {{mode}}.",
|
||||||
|
"request": "You may ask the main agent for: {{kinds}} (team_request).",
|
||||||
|
"model": "Your model: {{model}}.",
|
||||||
|
"limits": "Limits: the team is {{size}} of {{max}} agents; at most {{steps}} steps a turn; {{spawn}}",
|
||||||
|
"spawn_yes": "you add subagents.",
|
||||||
|
"spawn_no": "only the main agent adds subagents.",
|
||||||
|
"context": "Context: {{used}} of {{window}} tokens ({{percent}}%)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"findings": {
|
||||||
|
"about": "Spec 3.4.0: team_findings read's answer — the header, then one line per finding, oldest first; none: texts.no_findings after the header.",
|
||||||
|
"header": "Findings of {{name}} ({{id}}):",
|
||||||
|
"item": "{{n}}. {{text}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"context": {
|
"context": {
|
||||||
@@ -203,7 +313,7 @@
|
|||||||
"started": "Started in: {{path}} (relative to the project root)"
|
"started": "Started in: {{path}} (relative to the project root)"
|
||||||
},
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own.",
|
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own. Spec 3.3.0 (subagents.md §4): `team_own` after `team` (and after `team_lead`) in the same block when the session's model cannot serve subagents itself — it serves one request at a time — and another server's model could.",
|
||||||
"open": "<session-note>",
|
"open": "<session-note>",
|
||||||
"close": "</session-note>",
|
"close": "</session-note>",
|
||||||
"date": "Today is {{date}}.",
|
"date": "Today is {{date}}.",
|
||||||
@@ -217,11 +327,116 @@
|
|||||||
"personality": "The person chose a different personality:\n{{personality}}",
|
"personality": "The person chose a different personality:\n{{personality}}",
|
||||||
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
|
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
|
||||||
"team": "The team may have {{min}} to {{max}} agents, you included.",
|
"team": "The team may have {{min}} to {{max}} agents, you included.",
|
||||||
"team_lead": "Work through a team from the start: you lead, and members do the work — at least {{members}} of them.",
|
"team_lead": "Work through subagents from the start: you lead, and they do the work. The person opened {{members}} slots for them: fill them with team_add.",
|
||||||
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
|
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
|
||||||
|
"team_own": "This model serves one request at a time, so subagents cannot run on it: they need another model, on another server, that the person names in this chat.",
|
||||||
"team_stopped": "The person stopped {{names}}; they have left the team.",
|
"team_stopped": "The person stopped {{names}}; they have left the team.",
|
||||||
"empty": "(none)"
|
"empty": "(none)"
|
||||||
},
|
},
|
||||||
"mcp_wait_ms": 3000
|
"mcp_wait_ms": 3000
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"about": "Spec 3.4.0 (progress.md): one fixed set of phases every client shows the same way. `texts` are the shared i18n keys progress.<phase> (a variant: progress.<phase>.<variant>); `line` is drawn for every phase but stopped and failed; elapsed in whole seconds (5s, 1m 05s, 1h 02m). conformance/progress.json.",
|
||||||
|
"phases": [
|
||||||
|
"queued",
|
||||||
|
"waiting_for_model",
|
||||||
|
"loading_model",
|
||||||
|
"reading_prompt",
|
||||||
|
"thinking",
|
||||||
|
"writing",
|
||||||
|
"running_tool",
|
||||||
|
"waiting_for_person",
|
||||||
|
"waiting_for_subagents",
|
||||||
|
"compacting",
|
||||||
|
"retrying",
|
||||||
|
"stopped",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"queued": {
|
||||||
|
"ahead": "int?"
|
||||||
|
},
|
||||||
|
"waiting_for_model": {
|
||||||
|
"model": "string",
|
||||||
|
"reason": "busy|slots|group"
|
||||||
|
},
|
||||||
|
"loading_model": {
|
||||||
|
"model": "string"
|
||||||
|
},
|
||||||
|
"reading_prompt": {
|
||||||
|
"done": "int?",
|
||||||
|
"total": "int?"
|
||||||
|
},
|
||||||
|
"thinking": {},
|
||||||
|
"writing": {
|
||||||
|
"rate": "number?"
|
||||||
|
},
|
||||||
|
"running_tool": {
|
||||||
|
"tool": "string",
|
||||||
|
"what": "string?"
|
||||||
|
},
|
||||||
|
"waiting_for_person": {
|
||||||
|
"what": "approval|question|plan",
|
||||||
|
"tool": "string?"
|
||||||
|
},
|
||||||
|
"waiting_for_subagents": {
|
||||||
|
"running": "int"
|
||||||
|
},
|
||||||
|
"compacting": {},
|
||||||
|
"retrying": {
|
||||||
|
"reason": "string",
|
||||||
|
"in": "int",
|
||||||
|
"attempt": "int?"
|
||||||
|
},
|
||||||
|
"stopped": {
|
||||||
|
"reason": "person|steps|budget?"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"reason": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"texts": {
|
||||||
|
"queued": "Queued",
|
||||||
|
"queued.ahead": "Queued: {{ahead}} ahead",
|
||||||
|
"waiting_for_model": "Waiting for {{model}}: {{reason}}",
|
||||||
|
"loading_model": "Loading {{model}}",
|
||||||
|
"reading_prompt": "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||||
|
"reading_prompt.bare": "Reading the prompt",
|
||||||
|
"thinking": "Thinking",
|
||||||
|
"writing": "Writing: {{rate}} tok/s",
|
||||||
|
"writing.bare": "Writing",
|
||||||
|
"running_tool": "Running {{tool}}: {{what}}",
|
||||||
|
"running_tool.bare": "Running {{tool}}",
|
||||||
|
"waiting_for_person": "Waiting for you: {{what}}",
|
||||||
|
"waiting_for_subagents": "Waiting for subagents: {{running}} running",
|
||||||
|
"compacting": "Compacting the conversation",
|
||||||
|
"retrying": "Retrying in {{in}} s: {{reason}}",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"stopped.reason": "Stopped: {{reason}}",
|
||||||
|
"failed": "Failed: {{reason}}"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"busy": "it serves one request at a time and is answering another",
|
||||||
|
"slots": "all its slots are in use",
|
||||||
|
"group": "its server holds another model now"
|
||||||
|
},
|
||||||
|
"what": {
|
||||||
|
"approval": "allow {{tool}}?",
|
||||||
|
"approval.bare": "an approval",
|
||||||
|
"question": "a question",
|
||||||
|
"plan": "the plan"
|
||||||
|
},
|
||||||
|
"stopped_reason": {
|
||||||
|
"person": "by you",
|
||||||
|
"steps": "step limit",
|
||||||
|
"budget": "budget"
|
||||||
|
},
|
||||||
|
"line": "{{text}} · {{elapsed}}",
|
||||||
|
"no_elapsed": [
|
||||||
|
"stopped",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"what_max": 80,
|
||||||
|
"pace_per_second": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
|
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
|
||||||
"capacity": {
|
"capacity": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.",
|
"description": "How much the model's server can do at once, so a subagent on it is not started where it would evict or wait for the model already working (subagents.md §3).",
|
||||||
"properties": {
|
"properties": {
|
||||||
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
|
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one, so a subagent on another model of the session's model's group is refused (spec 3.3.0, subagents.md §3). The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
|
||||||
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." },
|
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." },
|
||||||
"parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. A team (team.md) is offered only on a model that serves at least 2." }
|
"parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. The session's model is its own subagents' model only when it serves at least 2; a one-request session needs a named model on another server (spec 3.3.0, subagents.md §3, §4)." }
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@
|
|||||||
"team_send": "allow",
|
"team_send": "allow",
|
||||||
"team_read": "allow",
|
"team_read": "allow",
|
||||||
"team_stop": "allow",
|
"team_stop": "allow",
|
||||||
|
"team_whoami": "allow",
|
||||||
|
"team_finish": "allow",
|
||||||
|
"team_findings": "allow",
|
||||||
|
"team_request": "allow",
|
||||||
|
"team_decide": "allow",
|
||||||
|
"team_assign": "allow",
|
||||||
|
"team_replace": "allow",
|
||||||
"web_search": "allow",
|
"web_search": "allow",
|
||||||
"memory": "allow",
|
"memory": "allow",
|
||||||
"session_search": "allow",
|
"session_search": "allow",
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
|
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
|
||||||
the mode text; LLeMbas's helper says the same. The variables are what differs per run. -->
|
the mode text; LLeMbas's subagent says the same. The variables are what differs per run. -->
|
||||||
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
|
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
|
||||||
|
|
||||||
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
|
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): how to lead a team, in the system prompt whenever
|
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §5–§13): how the main agent
|
||||||
the team tools are offered, and in a member's that may lead. Constant: the size the person
|
leads its subagents, in the system prompt whenever the team tools are offered. Constant: the
|
||||||
allows comes in a session note, never here. -->
|
size the person allows comes in a session note, never here. -->
|
||||||
# Leading a team
|
# Leading subagents
|
||||||
You can add members to a team with team_add: agents that work in the background, each on the part of the work it owns, while you lead. A small job you may do yourself. Once you have a member you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
You are the main agent of this session. You can add subagents with team_add: agents that work in the background, each on the part of the work it owns, while you lead. Only you add, assign, replace and stop them. A small job you may do yourself. Once you have a subagent you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
||||||
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one member owns a thing from start to finish.
|
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one subagent owns a thing from start to finish.
|
||||||
- Name each member after what it owns. Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
|
- Name each subagent after what it owns and give it a position (`role`). Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
|
||||||
- Keep the team flat while it is small — up to six or eight members, each reporting to you. Beyond that, add a lead for each area (`lead: true`), give it the area's goal, and let it build and run its own members; you then lead the leads.
|
- Keep the tree flat while it is small — up to six or eight subagents, each reporting to you. Beyond that, add a sub-manager for each area (`lead: true`) and place that area's subagents under it (`parent`); it directs them and reports to you, and you still add and stop every one.
|
||||||
- Keep the team within the size the person allows, which a session note tells you. Stop a member whose part is done or no longer needed, to make room.
|
- A subagent's mode is yours by default and never looser. Give a stricter one where the person wants less done unasked; with it you may give `request` options, and you decide its requests with team_decide — allow only what the task needs.
|
||||||
- Coordinate through the room: team_send to the member who owns a thing, `all` only for what everyone needs. team_read shows the team and the room when you need to look; nothing from it reaches you unasked except your members' reports and messages addressed to you.
|
- Keep within the size the person allows, which a session note tells you; when it says slots are open, fill them. Stop a subagent whose part is done or no longer needed, to make room.
|
||||||
- Step in when a member is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns.
|
- Coordinate through the room: team_send to the subagent who owns a thing, `all` only for what everyone needs. team_read shows the tree and the room when you need to look; nothing from it reaches you unasked except reports, requests and messages addressed to you.
|
||||||
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer: to the person, or, if you lead an area, as your report to your manager.
|
- A subagent whose context fills saves its findings and ends. Read them with team_findings, then replace it (team_replace) if its part is not done.
|
||||||
|
- Step in when a subagent is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns (team_assign for new work).
|
||||||
|
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer to the person.
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): a team member's standing, said once at the end
|
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md), 3.4.0 (subagents.md §13): a subagent's standing,
|
||||||
of its system prompt in place of blocks/subagent.md. The variables are what differs per member;
|
said once at the end of its system prompt in place of blocks/subagent.md. The variables are what
|
||||||
each stands on a line of its own, so no punctuation of theirs matters. -->
|
differs per subagent; each stands on a line of its own, so no punctuation of theirs matters. -->
|
||||||
You are "{{name}}", a member of a team, and you report to "{{manager}}".
|
You are "{{name}}" ({{id}}), a subagent of this session, and you report to "{{manager}}".
|
||||||
Your part: {{role}}
|
Your position: {{role}}
|
||||||
The team's goal: {{goal}}
|
The team's goal: {{goal}}
|
||||||
|
|
||||||
{{instructions}}
|
{{instructions}}
|
||||||
|
|
||||||
You work for your manager on the part you own, and you cannot ask the person anything: ask your manager, by name, with team_send. Other members own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone on the team needs; messages to you arrive as you work. team_read shows who is on the team and the room.
|
You work for your parent on the part you own, and you cannot ask the person anything: ask your parent with team_send to `parent`. Only the main agent (`lead`) adds, assigns and stops subagents. Other subagents own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone needs; messages to you arrive as you work. team_read shows the tree and the room; team_whoami shows who you are, your mode and how full your context is.
|
||||||
|
|
||||||
Your final reply is your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A message may wake you again afterwards; your next final reply is then your next report.{{read_only}}
|
Your context is limited and you do not compact: write what you find with team_findings as you go. When your context fills you are told, and you have one last turn to save your findings and tell your parent.
|
||||||
|
|
||||||
|
When your task is done, call team_finish with your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A final reply without it is also your report, and a message may then wake you again.{{read_only}}
|
||||||
|
|
||||||
{{lead}}
|
{{lead}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- LLeMbas CLI and LLeMbas, spec 3.4.0 (subagents.md §5, §13): what a sub-manager is told, in
|
||||||
|
place of the whole of blocks/team_lead.md that a member with `lead: true` got in 3.2.0. -->
|
||||||
|
# Directing an area
|
||||||
|
You are a sub-manager: the main agent places subagents under you, and you direct them. You do not add, assign, replace or stop subagents — ask the main agent with team_send to `lead` when your area needs one more, another task for one, or one stopped. You manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
|
||||||
|
- Split your area's work among the subagents under you, tell each what it owns with team_send, and keep them from overlapping.
|
||||||
|
- Their reports come to you. Review each against what you asked — read the work itself where it matters — and send back what is not done.
|
||||||
|
- Read the findings of a subagent under you with team_findings, especially of one that ran out of context; tell the main agent whether it needs replacing.
|
||||||
|
- When your area is done, call team_finish with your report to the main agent.
|
||||||
@@ -251,16 +251,23 @@
|
|||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
"llembas": [],
|
"llembas": [],
|
||||||
"llembas_text": "identical",
|
"llembas_text": "identical",
|
||||||
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment",
|
"why": "spec 3.2.0, 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment",
|
||||||
"variables": {
|
"variables": {
|
||||||
"name": "the member's name in the team",
|
"name": "the member's name in the team",
|
||||||
"role": "its role as team_add gave it: one line, what it owns",
|
"role": "its position, one line (team_add's role)",
|
||||||
"manager": "the name of the agent that added it (`lead` for the team's lead)",
|
"manager": "its parent: lead, or the sub-manager it was placed under",
|
||||||
"goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)",
|
"goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)",
|
||||||
"instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md",
|
"instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md",
|
||||||
"read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty",
|
"read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty",
|
||||||
"lead": "for a member added with `lead: true`: the whole of blocks/team_lead.md; otherwise empty"
|
"lead": "for a sub-manager (added with `lead: true`): the whole of blocks/team_submanager.md (spec 3.4.0; before, blocks/team_lead.md); otherwise empty",
|
||||||
|
"id": "the subagent's id (s1, s2, …; spec 3.4.0)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocks/team_submanager.md": {
|
||||||
|
"scope": "shared",
|
||||||
|
"llembas": [],
|
||||||
|
"llembas_text": "identical",
|
||||||
|
"why": "spec 3.4.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Subagents
|
||||||
|
|
||||||
|
Spec 3.3.0; §5–§13 spec 3.4.0. **Which model a subagent runs on, and when subagents are offered at all.** A
|
||||||
|
**subagent** is any agent a session's agent starts to work for it: a `task` subagent (in the
|
||||||
|
foreground or as a background job) and a team's member (`team.md`) alike. The same rules in a chat
|
||||||
|
on the server and in an agent session on a device, in both projects. The texts are `loop.json`
|
||||||
|
`subagents`; the cases `conformance/subagents.json`.
|
||||||
|
|
||||||
|
## 1. The word
|
||||||
|
|
||||||
|
**Subagent** is the word in every text a person or a model reads: the UI (the web UI, Desktop,
|
||||||
|
Android, the CLI, the admin pages), the prompt texts and the tool texts. A team's members are
|
||||||
|
subagents too. The word *helper* is retired. Tool names, their former names (`aliases`) and
|
||||||
|
stored identifiers do not change.
|
||||||
|
|
||||||
|
## 2. The model a subagent runs on
|
||||||
|
|
||||||
|
**The session's own model**, always — unless **the person has named another model** in this
|
||||||
|
session. Nothing else chooses one: there is **no setting** for a subagent's model anywhere — not
|
||||||
|
in a chat, not in a person's or an instance's settings, not in the configuration, and an agent
|
||||||
|
definition's own `model` (the CLI's agent files) is not used for a subagent either.
|
||||||
|
|
||||||
|
- **Named** means: in a message **the person typed** in this session, the model's served id, its
|
||||||
|
id at its connection, or its display name occurs as a whole word, letters compared without case.
|
||||||
|
Only the person's own words count — never the model's text, a tool's output, a file, a note, a
|
||||||
|
job's or a team's marked message, or a subagent's report. A message that was compacted away
|
||||||
|
still counts: what the person named stays named for the whole session.
|
||||||
|
- **A whole word**: the character before the name is not a letter, a digit, `_`, `-`, `/`, `.` or
|
||||||
|
`:`; the character after it is not a letter, a digit, `_`, `-` or `/`, nor a `.` or `:` followed
|
||||||
|
by a letter or a digit. So `coder-30b.` at the end of a sentence names `coder-30b`, and
|
||||||
|
`coder-30b-instruct` does not. Runs of white space in a display name match any run of white
|
||||||
|
space.
|
||||||
|
- The `model` parameter of `task` and of `team_add` takes a model the person may use, by the same
|
||||||
|
names. When it is **not the session's model and the person has not named it**, the call is
|
||||||
|
refused, word for word, with `subagents.texts.not_named`:
|
||||||
|
|
||||||
|
> Use another model for subagents only when the person names it.
|
||||||
|
|
||||||
|
The session's own model given as `model` is the default spelled out, and is never refused for
|
||||||
|
not being named.
|
||||||
|
- A team's members are of the lead's session: what the person named there counts for every
|
||||||
|
`team_add` in the team, a sub-manager's included. A member's default is the session's model, not
|
||||||
|
its manager's.
|
||||||
|
|
||||||
|
## 3. Capacity: the logical restrictions, and no others
|
||||||
|
|
||||||
|
What a model's server can do at once is `models/schema.json` `capacity`: `parallel` (`single_session`
|
||||||
|
being `parallel: 1`; neither said — no limit), and `group`, the server that holds one model at a
|
||||||
|
time. With **S** the session's model and **M** the subagent's, checked in this order, after
|
||||||
|
`not_named`:
|
||||||
|
|
||||||
|
1. **M is another model on S's one-model server** (both have the same `group`): refused with
|
||||||
|
`subagents.texts.same_group` — starting it would unload the lead's model:
|
||||||
|
|
||||||
|
> That model is on the same one-model server as this chat's model; name a model on another server.
|
||||||
|
|
||||||
|
2. **M is S, and S serves one request at a time** (`parallel` 1): refused with
|
||||||
|
`subagents.texts.one_request` — the subagent would take the only slot from the lead:
|
||||||
|
|
||||||
|
> This model serves one request at a time: name another model, on another server, for subagents.
|
||||||
|
|
||||||
|
There is **no fallback**: nothing else is picked for the person, ever. A model the person named,
|
||||||
|
on another server, is the way out — and a model that serves one request at a time is fine there.
|
||||||
|
|
||||||
|
These apply to `task` and to `team_add` alike, and before anything is started. Beyond them nothing
|
||||||
|
is refused for being the wrong model: a member past the free slots waits for one (`team.md` §8),
|
||||||
|
and `task` keeps its rule for a model that is already full (`loop.json` `capacity`).
|
||||||
|
|
||||||
|
## 4. When subagents are offered
|
||||||
|
|
||||||
|
The size setting `agents {min, max}` (`team.md` §2) is shown as **Subagents** — `subagents.setting`:
|
||||||
|
the label, and **Min** and **Max** for `min` and `max` (defaults 1 and 4, at most `cap` 32, kept
|
||||||
|
with the chat). **The numbers count the session's own agent**: Min 1 / Max 1 is the agent alone,
|
||||||
|
with no subagents; Max 4 is the agent and up to three subagents (`subagents.setting.hint`). A UI
|
||||||
|
shows the setting as **one range from 1 to `cap` with two handles, Min and Max** (Min never past
|
||||||
|
Max): a slider in a graphical UI, two numbers on one line in a terminal, each moved on its own. It, the team tools and `blocks/team_lead.md` are **offered** when both hold:
|
||||||
|
|
||||||
|
1. Subagents are enabled for the session (`team.md` §2, rule 1).
|
||||||
|
2. **At least one model could serve as a subagent**: S itself, when it serves two or more requests
|
||||||
|
at once (`parallel ≥ 2`, or no limit); or **any other model the person may use that is not on
|
||||||
|
S's one-model server** (no `group`, or another one) — whatever its own `parallel`.
|
||||||
|
|
||||||
|
So a session on a one-request model is offered subagents when another server has a model, and
|
||||||
|
**its first note says so** (`context.note.team_own`, after `team`): the session's model cannot
|
||||||
|
serve subagents itself, and they need a model the person names. A UI showing the setting says the
|
||||||
|
same beside it (`subagents.setting.own`). Nothing is offered when neither holds — the same as
|
||||||
|
before 3.3.0 for a single model.
|
||||||
|
|
||||||
|
What counts as **usable** is each project's: LLeMbas — the models the person may use in the chat's
|
||||||
|
picker; the CLI — the models of the server it is logged in to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Subagents v2 — the main agent and its tree
|
||||||
|
|
||||||
|
Spec 3.4.0. **How subagents are organised, what each may do, and how each ends.** The owner's model:
|
||||||
|
one permanent **main agent** per session, which alone makes subagents and places them in a tree;
|
||||||
|
permissions are inherited and never widened; a subagent is temporary and ends with its task or its
|
||||||
|
context. Sections 5–13 **supersede `team.md` where they say something different** — who adds a
|
||||||
|
subagent (§6), who stops one (§10), the states (§9), what a member is told (§13) — and keep the
|
||||||
|
rest of it: the size setting and its counting (§2–§3), the room and who receives what (§5),
|
||||||
|
delivery and waking (§7), a manager's refusals (§6 there), capacity and waiting for a slot (§8).
|
||||||
|
The tool names `team_*` stay (nothing stored or trained breaks); `task` and `report` are unchanged
|
||||||
|
for a one-off subagent. The texts are `loop.json` `team` (3.4.0 additions marked there); the cases
|
||||||
|
`conformance/team.json` (the tree, the tools, the lifecycle) and `conformance/delegation.json`
|
||||||
|
(permissions).
|
||||||
|
|
||||||
|
## 5. Words
|
||||||
|
|
||||||
|
- **The main agent** is the session's own agent: the chat's agent on LLeMbas, the session's in the
|
||||||
|
CLI. It is **permanent**: it compacts and never ends. In the room and in every tool it is called
|
||||||
|
`lead` (the name is kept from 3.2.0); a person reads it as *the main agent*.
|
||||||
|
- **A subagent** is an agent the main agent added (`team_add`). It has:
|
||||||
|
- **`id`** — `s1`, `s2`, … in the order the session's subagents were added, never reused in the
|
||||||
|
session (a replacement gets a new one);
|
||||||
|
- **`name`** — the main agent's choice (`team.names.pattern`), unique among the subagents that
|
||||||
|
have not ended, `lead`, `all` and `parent` reserved;
|
||||||
|
- **`position`** — its designation, one line, given by the main agent (`team_add`'s `role`:
|
||||||
|
*the API's tests*, *reviews the parser*);
|
||||||
|
- **`parent`** — the main agent, or a **sub-manager** the main agent placed it under;
|
||||||
|
- **`task`** — its brief, as given (and as replaced by `team_assign`);
|
||||||
|
- **`mode`** and **`request`** — its permission mode and its request options (§7);
|
||||||
|
- **`model`** — `subagents.md` §2–§3, unchanged.
|
||||||
|
- **A sub-manager** is a subagent added with `lead: true`. It **directs** the subagents the main
|
||||||
|
agent places under it — they report to it, it messages them, it reviews their work — and it does
|
||||||
|
**not** add, assign, replace or stop subagents: only the main agent does (§6). Like every manager
|
||||||
|
it does not do the work itself (`team.md` §6, unchanged).
|
||||||
|
- **The tree**: the main agent at the root, every subagent under its parent, in the order added.
|
||||||
|
**Under** an agent and **its subtree** keep their `team.md` meaning.
|
||||||
|
|
||||||
|
## 6. Only the main agent makes subagents
|
||||||
|
|
||||||
|
- **`team_add`, `team_assign`, `team_replace`, `team_stop` and `team_decide`** are the main agent's.
|
||||||
|
A subagent's call to any of them is refused, word for word, with `team.texts.only_main`:
|
||||||
|
|
||||||
|
> Only the main agent adds, assigns, replaces and stops subagents: ask it (team_send to lead).
|
||||||
|
|
||||||
|
A subagent's tool list never has `task` (a one-off subagent is the main agent's too), and a call
|
||||||
|
to it by a former name is refused with the same text.
|
||||||
|
- **`team_add`** takes, beside 3.2.0's `name`, `role` (the position), `task`, `goal`, `lead`,
|
||||||
|
`agent`, `model`: **`parent`** — the name of a sub-manager to place it under (default: the main
|
||||||
|
agent; refused with `team.texts.not_manager` for a subagent that is not a sub-manager, and
|
||||||
|
`unknown` for no such name), **`mode`** — its mode (default: its parent's; §7), **`request`** —
|
||||||
|
its request options (§7). Its refusals, in order: `unavailable`, `only_main`, then 3.2.0's (`all_name`,
|
||||||
|
`bad_name`, `taken`, `no_goal`, `full`), then `unknown` and `not_manager` for `parent`, then the mode's (`wider`, `request_not_narrower`,
|
||||||
|
`request_kind`), then the model's (§2–§3). `parent` and the reserved names are refused as
|
||||||
|
`taken`.
|
||||||
|
- **The person** can tell the main agent to spawn — it is the main agent's call, as any — or set
|
||||||
|
**Min above 1**: then **`min − 1` slots** exist from the start of the main agent's reply (the
|
||||||
|
first one after the setting was told, `team.md` §10). A **slot** is a place in the tree for a
|
||||||
|
subagent not added yet: it is shown (§12) and `team_read` lists it, it is **not** an agent and
|
||||||
|
does not count toward the size, and each subagent added fills one. Open slots are
|
||||||
|
`max(0, min − 1 − subagents that have not ended)`. The main agent is told to use them in its
|
||||||
|
note (`context.note.team_lead`, which 3.4.0 words as slots), and is a manager from the start
|
||||||
|
(`team.md` §6).
|
||||||
|
- **Min/Max** (3.3.0) are unchanged: they count the main agent; 1 is the main agent alone. The
|
||||||
|
model rules (§2–§3) are unchanged.
|
||||||
|
|
||||||
|
## 7. Permissions: inherited, never widened
|
||||||
|
|
||||||
|
**A subagent's mode is its parent's or stricter** — `permission/modes.json` `order`, loosest first:
|
||||||
|
`auto`, `edit`, `manual`, `plan`. The main agent's mode is the session's.
|
||||||
|
|
||||||
|
- `team_add`'s and `team_assign`'s `mode`, looser than the parent's, is refused, word for word,
|
||||||
|
with `team.texts.wider`:
|
||||||
|
|
||||||
|
> A subagent's mode cannot be looser than its parent's ({{parent_mode}}).
|
||||||
|
|
||||||
|
When the parent's own mode becomes stricter during the session (the person switches the main
|
||||||
|
agent to manual), every subagent looser than its parent is made as strict as it, down the tree,
|
||||||
|
at once; a looser switch changes no subagent.
|
||||||
|
- **Request options** (`request`: a list of the risk classes `read`, `write`, `execute`) may be
|
||||||
|
given **only with a mode stricter than the parent's** — otherwise refused with
|
||||||
|
`team.texts.request_not_narrower` ("Request options go with a mode stricter than the parent's:
|
||||||
|
without one there is nothing to ask for."); an unknown kind with `team.texts.request_kind`. They
|
||||||
|
let the subagent **ask the main agent** for a call of that class that its own mode does not
|
||||||
|
allow; **the main agent is the check**.
|
||||||
|
|
||||||
|
**Who decides a subagent's call.** The hardline and the deny rules first, as for any call. Then:
|
||||||
|
|
||||||
|
1. The call is evaluated in the **subagent's mode**, with the session's rules and approvals and
|
||||||
|
the subagent's own grants (below). **Allow** — it runs. **Deny** (a rule, the hardline, plan
|
||||||
|
mode's refusal) — refused, as always.
|
||||||
|
2. **Ask** — the call is evaluated in the **main agent's mode** (the session's rules and
|
||||||
|
approvals, no grants):
|
||||||
|
- **Allow** — the main agent could allow it: the subagent was narrowed. If the call's risk class
|
||||||
|
is in its `request`, refused with `team.texts.requestable` — it asks with `team_request`, the
|
||||||
|
main agent decides with `team_decide`, and once allowed the same call runs. Otherwise refused
|
||||||
|
with `team.texts.not_delegated`:
|
||||||
|
|
||||||
|
> Your mode does not allow this, and you may not ask for it: tell your parent what you need.
|
||||||
|
|
||||||
|
- **Ask** — **nobody in the tree can allow it: it goes to the person**, as an approval card that
|
||||||
|
names the subagent (its name and position), the same card and the same answers as the main
|
||||||
|
agent's own; unattended, refused as `modes.json` `unattended` says. So in an all-Edit tree a
|
||||||
|
subagent's `bash` reaches the person — not the main agent, which could not allow it either.
|
||||||
|
- **Deny** — refused with that reason.
|
||||||
|
|
||||||
|
A sub-manager is never asked: it may not allow more than the main agent, and the main agent is
|
||||||
|
the check.
|
||||||
|
|
||||||
|
**`team_request`** (a subagent with request options; its tool list has it only then): `tool`, the
|
||||||
|
permission `pattern` it needs (as a rule writes it: `git push *`, a path) and `why`. Refused with
|
||||||
|
`team.texts.not_requestable` when the tool's risk class is not in its options. Posted to the main
|
||||||
|
agent as a request, under its own mark (`team.marks.request`), the body `team.texts.request_body`,
|
||||||
|
with an id (`r1`, `r2`, … per session); the answer `team.texts.requested` says it waits for the main agent's decision.
|
||||||
|
|
||||||
|
**`team_decide`** (the main agent): `id`, `allow` (a boolean), `note`. Allowed — the subagent gets
|
||||||
|
a **grant**: the rule `{ permission: <tool's key>, pattern, action: allow }` for itself alone, for
|
||||||
|
the rest of its life; **a grant only ever applies where the main agent's own mode would allow the
|
||||||
|
call** (step 2's evaluation, done again at the call), so it can never widen past the main agent.
|
||||||
|
Either way the subagent is told, under `team.marks.decision`: `team.texts.decision_allowed` or
|
||||||
|
`decision_refused`, then the note after a blank line. The answer to the main agent is
|
||||||
|
`team.texts.decided` (`decision` being `allowed` or `refused`). Refused:
|
||||||
|
`team.texts.no_request` for an id that is not an open request.
|
||||||
|
|
||||||
|
## 8. What each agent has
|
||||||
|
|
||||||
|
| Tools | Main agent | Sub-manager | Subagent |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `team_whoami`, `team_read`, `team_send`, `team_findings` | ✓ | ✓ | ✓ |
|
||||||
|
| `team_finish` | — | ✓ | ✓ |
|
||||||
|
| `team_request` | — | with request options | with request options |
|
||||||
|
| `team_add`, `team_assign`, `team_replace`, `team_stop`, `team_decide` | ✓ | — | — |
|
||||||
|
|
||||||
|
Fixed when the prompt is made (the main agent's) or when the subagent is added (`context.md` §10,
|
||||||
|
rule 4). What each does:
|
||||||
|
|
||||||
|
- **`team_whoami`** — who the caller is: `team.whoami`, one line each, in its order — id, name, position, parent, task, mode and
|
||||||
|
request options, model, the limits (the size and Max, the steps a turn may take, whether it may
|
||||||
|
add subagents) and its **context use** (tokens in its context now, of its model's window, and the
|
||||||
|
percentage). For the main agent: the same, its position *the main agent*, without the parent and task lines.
|
||||||
|
- **`team_read`** — the roster: the tree (every agent and open slot, §12's fields) and the room,
|
||||||
|
as in 3.2.0 (`team.read`; 3.4.0's row adds the id, the mode and the context use).
|
||||||
|
- **`team_send`** — a message `to` names, `all`, or **`parent`** (3.4.0): the caller's parent
|
||||||
|
(refused for the main agent with `team.texts.no_parent`). Otherwise 3.2.0.
|
||||||
|
- **`team_finish`** — `report`: the subagent's task is done. The report goes to its parent (the
|
||||||
|
report mark, as a final reply's), and the subagent **ends** (§9): its turn stops after this call,
|
||||||
|
it leaves the size, and its findings and transcript stay.
|
||||||
|
- **`team_findings`** — `write` (`text`): a finding kept for the caller, numbered from 1; `read`
|
||||||
|
(`of`: a name or an id, default the caller): the findings of the caller, or of an agent in its
|
||||||
|
subtree (the main agent: anybody's), ended ones included — as `team.findings` writes them. A
|
||||||
|
name that has been used more than once means its latest holder; an id is exact. Otherwise refused with
|
||||||
|
`team.texts.not_yours_findings`. The person reads every agent's (§12).
|
||||||
|
- **`team_assign`** — `name`, `task`, and optionally `role` (a new position), `parent` (another
|
||||||
|
sub-manager, or `lead`), `mode` (§7): a new task for a subagent that has not ended. Its task
|
||||||
|
becomes this one; it is told under `team.marks.assignment`, and a finished one is woken. Refused:
|
||||||
|
`unknown`, `team.texts.ended` for one that has ended, `not_manager`, `wider`; a move under its
|
||||||
|
own subtree with `team.texts.cycle`.
|
||||||
|
- **`team_replace`** — `name`, and optionally `task`: the subagent ends (stopped, if it has not
|
||||||
|
already), and a new one takes its place — the same name, position, parent, mode, request options
|
||||||
|
and model, a new id — briefed with `task` (default: the old task) followed by the old one's
|
||||||
|
findings (`team.texts.replace_brief`). Its subagents move under the new one. Refused: `unknown`.
|
||||||
|
- **`team_stop`** — the main agent's only (3.2.0 let a manager stop its subtree); otherwise 3.2.0.
|
||||||
|
- **`team_decide`** — §7.
|
||||||
|
|
||||||
|
## 9. A subagent's life
|
||||||
|
|
||||||
|
States (`team.read.states`): **`working`** (a turn runs), **`waiting`** (it has work and waits for
|
||||||
|
a slot, `team.md` §8), **`finished`** (its last turn ended with a final reply, which was its report;
|
||||||
|
a message wakes it — 3.2.0), **`paused`** (its context is full; its last turn runs), and the ends:
|
||||||
|
**`done`** (`team_finish`), **`out_of_context`** (its last turn after `paused` ended), **`stopped`**
|
||||||
|
(the main agent's `team_stop`, a replacement, or the person). The main agent is `working`,
|
||||||
|
`waiting` or `idle`.
|
||||||
|
|
||||||
|
**An ended subagent** — `done`, `out_of_context`, `stopped` — leaves the size and its slot frees;
|
||||||
|
its name may be used again; it keeps its id, and the tree keeps it (§12) with its findings and
|
||||||
|
transcript for the rest of the session. Messages to it by name are refused as `unknown`.
|
||||||
|
The subagents under a sub-manager that ended by itself (`done`, `out_of_context`) move under its
|
||||||
|
parent until the main agent replaces it, and then back under the replacement; a stopped one's
|
||||||
|
subtree is stopped with it (§10).
|
||||||
|
|
||||||
|
**When its context fills.** A subagent does not compact. When its context reaches
|
||||||
|
`team.context.pause_at` of its model's window (default 0.85, the fraction compaction would start
|
||||||
|
at), after the step that took it there:
|
||||||
|
|
||||||
|
1. it is **`paused`**: nothing new reaches it — a message to it is posted in the room and not
|
||||||
|
delivered; whoever replaces it reads it with `team_read`;
|
||||||
|
2. it is given **one final turn**, at most `team.context.final_steps` steps (8), whose user message
|
||||||
|
is `team.texts.context_full`, under its own mark: save what it found with `team_findings`, and
|
||||||
|
tell its parent with `team_send` that it is gone and what is left;
|
||||||
|
3. when that turn ends it is **`out_of_context`**, and its parent is sent `team.texts.gone` — then,
|
||||||
|
after a blank line, that turn's final reply if it gave one — as its report (the report mark): the name, that it ran out of context, and how to read its
|
||||||
|
findings. **The parent decides** — the main agent replaces it (`team_replace`) or uses the
|
||||||
|
findings; a sub-manager tells the main agent.
|
||||||
|
|
||||||
|
**The main agent is permanent**: it compacts as every session does (`context.md` §7) and never
|
||||||
|
ends; deleting the session ends every subagent.
|
||||||
|
|
||||||
|
**Waiting for subagents.** The main agent's turn may end while subagents work; their reports wake
|
||||||
|
it (3.2.0 §7). Its progress is `waiting_for_subagents` (`progress.md`) while its turn waits on
|
||||||
|
them — a foreground `task` — and the session shows the subagents' own phases in the tree.
|
||||||
|
|
||||||
|
## 10. Stopping
|
||||||
|
|
||||||
|
Only the main agent stops (`team_stop`, 3.2.0's refusals but `not_yours`, which no longer arises),
|
||||||
|
and **the person** (§12) — said to the main agent as a note (`context.note.team_stopped`). A
|
||||||
|
stopped subagent's subtree is stopped with it, unless the main agent moved it first. A sub-manager
|
||||||
|
that wants a subagent of its own stopped asks the main agent.
|
||||||
|
|
||||||
|
## 11. Transcripts and findings
|
||||||
|
|
||||||
|
Every subagent's conversation is kept — its brief, what it wrote, every tool call with its output —
|
||||||
|
for as long as the session: **viewable read-only by the person, live while it runs and after it
|
||||||
|
ended**. Nobody types into it; the person speaks to a subagent only through the main agent (or by
|
||||||
|
stopping it). Findings (§8) are kept the same way, readable by its parent, the main agent, and the
|
||||||
|
person.
|
||||||
|
|
||||||
|
## 12. What clients show
|
||||||
|
|
||||||
|
**The tree** — the main agent, every subagent under its parent, open slots, and ended subagents
|
||||||
|
(after the live ones, dimmed, with how each ended):
|
||||||
|
|
||||||
|
| Field | Shown as |
|
||||||
|
|---|---|
|
||||||
|
| `name`, `id`, `position` | `parser` (`s2`) — *reviews the parser* |
|
||||||
|
| `state` | `team.read.states`, its word |
|
||||||
|
| `model` | display name |
|
||||||
|
| `mode` | the mode's name, and request options as `+ write` etc. |
|
||||||
|
| `context` | `{used, window}` → `42%` (a bar where there is room) |
|
||||||
|
| `progress` | its current phase line (`progress.md` §3) |
|
||||||
|
| messages | the room's messages to and from it: how many unread by the person, the last one's first line |
|
||||||
|
|
||||||
|
- **Web UI, Desktop and the CLI** show the whole tree, the room, each subagent's findings and its
|
||||||
|
context (the CLI: `/context <name>`), and open any subagent's transcript read-only, live and after
|
||||||
|
it ended; the person can stop any subagent there.
|
||||||
|
- **Android** shows a reduced list — name, position, state, progress line, context % — and opens a
|
||||||
|
transcript read-only.
|
||||||
|
|
||||||
|
**The data**, the same in `/api/v2` (LLeMbas: a chat's stream carries the tree as it changes) and on
|
||||||
|
the link (`acp.md`, capability `tree`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "min": 1, "max": 4, "size": 2, "open_slots": 0,
|
||||||
|
"agents": [
|
||||||
|
{ "id": "lead", "name": "lead", "position": "the main agent", "parent": null, "state": "working",
|
||||||
|
"model": "qwen-27b", "mode": "edit", "request": [], "manager": true,
|
||||||
|
"context": { "used": 41200, "window": 131072 }, "progress": { "phase": "thinking", "since": 1762790400000 } },
|
||||||
|
{ "id": "s1", "name": "parser", "position": "reviews the parser", "parent": "lead", "state": "working",
|
||||||
|
"model": "qwen-27b", "mode": "manual", "request": ["write"], "manager": false, "task": "…",
|
||||||
|
"context": { "used": 9100, "window": 131072 }, "progress": { "phase": "running_tool", "since": 1762790401000, "tool": "read", "what": "src/parse.ts" },
|
||||||
|
"findings": 2, "messages": 3 }
|
||||||
|
],
|
||||||
|
"ended": [ { "id": "s0", "name": "scout", "position": "…", "parent": "lead", "state": "done", "ended": 1762790300000, "findings": 1 } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. What each is told
|
||||||
|
|
||||||
|
- The main agent: `blocks/team_lead.md` (3.4.0: it alone adds; slots; positions; modes and request
|
||||||
|
options; replacing a subagent that ran out of context) and the notes of `team.md` §10.
|
||||||
|
- A subagent: `blocks/team_member.md` (3.4.0: its id and position, `team_whoami`, `team_findings`,
|
||||||
|
`team_finish`, that its context is limited and it will be told when it fills); a sub-manager,
|
||||||
|
`blocks/team_submanager.md` in place of the whole lead block it got in 3.2.0.
|
||||||
|
|
||||||
|
## 14. In an agent chat
|
||||||
|
|
||||||
|
The main agent of an agent chat is the CLI on the linked device: it starts and runs the
|
||||||
|
subagents there, under every rule above. The server carries the chat's Subagents setting down with
|
||||||
|
every turn and the tree, approvals, findings and transcripts up, and offers no subagents of its own
|
||||||
|
to an agent chat. The protocol: `acp.md`, *Subagents in an agent chat* (capabilities `agents` and
|
||||||
|
`tree`).
|
||||||
|
|
||||||
+46
-18
@@ -1,6 +1,6 @@
|
|||||||
# Teams
|
# Teams
|
||||||
|
|
||||||
Spec 3.2.0. **Subagents that work together.** A session's agent — the **lead** — may add
|
Spec 3.2.0; the model a member runs on and when a team is offered, 3.3.0 (`subagents.md`); the main agent, the tree, permissions and a subagent's life, 3.4.0 (`subagents.md` §5–§13). **Subagents that work together.** A session's agent — the **lead** — may add
|
||||||
**members**, background agents that each own a part of the work, and lead them as a manager: it
|
**members**, background agents that each own a part of the work, and lead them as a manager: it
|
||||||
plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other
|
plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other
|
||||||
and to their managers in the team's **room**. A member may itself lead an area with members of its
|
and to their managers in the team's **room**. A member may itself lead an area with members of its
|
||||||
@@ -11,6 +11,16 @@ The tools are `tools/team_add.json`, `team_send.json`, `team_read.json`, `team_s
|
|||||||
and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts
|
and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts
|
||||||
`prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`.
|
`prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`.
|
||||||
|
|
||||||
|
> **Spec 3.4.0 supersedes parts of this page** (`subagents.md` §5–§13, *Subagents v2*): the lead is
|
||||||
|
> **the main agent**, and **only it adds, assigns, replaces and stops** — a member with `lead: true`
|
||||||
|
> is a **sub-manager** that directs the members placed under it (`team_add`'s `parent`) and does not
|
||||||
|
> add or stop (§4, §9 below); members have a mode no looser than their parent's and may ask the main
|
||||||
|
> agent for more (`subagents.md` §7); a member ends with `team_finish` or when its context fills
|
||||||
|
> (`subagents.md` §9); the room carries assignments, requests and decisions besides messages and
|
||||||
|
> reports; the tree goes over the link (§11). Everything else here stands as written: the size
|
||||||
|
> setting and its counting, the room and who receives what, delivery and waking, a manager's
|
||||||
|
> refusals, capacity and waiting for a slot. "Member" here and "subagent" there are the same thing.
|
||||||
|
|
||||||
## 1. Words
|
## 1. Words
|
||||||
|
|
||||||
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
|
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
|
||||||
@@ -29,6 +39,8 @@ and numbers `loop.json` `team` (and the size notes in `context.note`); the promp
|
|||||||
## 2. The size setting, and when a team is offered
|
## 2. The size setting, and when a team is offered
|
||||||
|
|
||||||
A chat has the setting **`agents: {min, max}`** — the agents the team may have, the lead included.
|
A chat has the setting **`agents: {min, max}`** — the agents the team may have, the lead included.
|
||||||
|
**The numbers count the chat's own agent**: `1` is that agent alone, with no subagents; `max` 4 is
|
||||||
|
that agent and up to three subagents. Nothing that shows or reads the setting counts subagents only.
|
||||||
|
|
||||||
- `1 ≤ min ≤ max ≤ cap`. `cap` is the instance's (LLeMbas) or the global configuration's (the CLI),
|
- `1 ≤ min ≤ max ≤ cap`. `cap` is the instance's (LLeMbas) or the global configuration's (the CLI),
|
||||||
default `loop.json` `team.agents.cap` (32); a project's configuration never raises it. A value
|
default `loop.json` `team.agents.cap` (32); a project's configuration never raises it. A value
|
||||||
@@ -46,23 +58,31 @@ system prompt — when both hold, and only then:
|
|||||||
|
|
||||||
1. Subagents are enabled for the session: LLeMbas — the instance allows them and the chat's model
|
1. Subagents are enabled for the session: LLeMbas — the instance allows them and the chat's model
|
||||||
has its subagent tool; the CLI — its subagents are not switched off in the configuration.
|
has its subagent tool; the CLI — its subagents are not switched off in the configuration.
|
||||||
2. **The chat's model serves at least two requests at once**: `capacity.parallel ≥ 2`, or neither
|
2. **At least one model could serve as a subagent** (spec 3.3.0, `subagents.md` §4): the chat's
|
||||||
`parallel` nor `single_session` is set (a hosted API; `models/schema.json`).
|
model, when it serves at least two requests at once (`capacity.parallel ≥ 2`, or neither
|
||||||
|
`parallel` nor `single_session` is set — a hosted API; `models/schema.json`); or any other model
|
||||||
|
the person may use that is not on the chat's model's one-model server (`capacity.group`).
|
||||||
|
|
||||||
|
The setting is shown as **Subagents**, with **Min** and **Max** (`loop.json` `subagents.setting`):
|
||||||
|
**one range from 1 to `cap` with two handles**, Min and Max, where Min never passes Max — a slider in
|
||||||
|
a graphical UI; in a terminal, the two numbers on one line, each moved on its own. Beside it, its
|
||||||
|
hint (`subagents.setting.hint`, or `short` where a line has no room): the count includes the agent,
|
||||||
|
and 1 is the agent alone. Never *fewest* / *most*, and never a count of subagents alone.
|
||||||
|
|
||||||
Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes
|
Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes
|
||||||
a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is
|
a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is
|
||||||
listed, and the session's `task` works as before.
|
listed, and the session's `task` works as before.
|
||||||
|
|
||||||
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and the model still
|
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and a model could
|
||||||
serves two at once — or, for the lead, when its team still has a member (so a lead whose person
|
still serve as a subagent (rule 2) — or, for the lead, when its team still has a member (so a lead whose person
|
||||||
lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not
|
lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not
|
||||||
met answers, word for word, `team.unavailable.one_request` when the model's capacity no longer
|
met answers, word for word, `team.unavailable.one_request` when no model could serve as a subagent
|
||||||
allows a team (its metadata changed after the prompt was made), else `team.unavailable.one_agent`
|
any more (the model's metadata changed after the prompt was made), else `team.unavailable.one_agent`
|
||||||
— which is also the tools' own `unavailable` text.
|
— which is also the tools' own `unavailable` text.
|
||||||
|
|
||||||
The meaning of the setting:
|
The meaning of the setting:
|
||||||
|
|
||||||
- **`max = 1`**: one agent, no team, and no helper of any kind: `task` answers `one_agent` too
|
- **`max = 1`**: one agent, no team, and no subagent of any kind: `task` answers `one_agent` too
|
||||||
(while the lead has no member; with members left from a larger setting, `full`).
|
(while the lead has no member; with members left from a larger setting, `full`).
|
||||||
- **`min ≥ 2`**: the lead works through a team from the start — it is a manager before its first
|
- **`min ≥ 2`**: the lead works through a team from the start — it is a manager before its first
|
||||||
member (§6), and the note asks for at least `min − 1` members.
|
member (§6), and the note asks for at least `min − 1` members.
|
||||||
@@ -72,14 +92,14 @@ The meaning of the setting:
|
|||||||
## 3. Size, and what counts
|
## 3. Size, and what counts
|
||||||
|
|
||||||
**The size** of a team is: the lead, every member (whatever its state — a finished member can be
|
**The size** of a team is: the lead, every member (whatever its state — a finished member can be
|
||||||
woken by a message, so it keeps its place), and every `task` helper running in the team, started by
|
woken by a message, so it keeps its place), and every `task` subagent running in the team, started by
|
||||||
any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run).
|
any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run).
|
||||||
A stopped member does not count: stopping is how room is made.
|
A stopped member does not count: stopping is how room is made.
|
||||||
|
|
||||||
- **`team_add` is refused past `max`** — when the size would become more than `max`, with
|
- **`team_add` is refused past `max`** — when the size would become more than `max`, with
|
||||||
`team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when
|
`team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when
|
||||||
`max` is 1 and the lead has no member. A `job_send` that would start a finished background
|
`max` is 1 and the lead has no member. A `job_send` that would start a finished background
|
||||||
`task` helper again is refused the same way.
|
`task` subagent again is refused the same way.
|
||||||
- **Lowering `max` stops nobody.** Members already there stay; nothing new is added until the size
|
- **Lowering `max` stops nobody.** Members already there stay; nothing new is added until the size
|
||||||
is under the new `max`.
|
is under the new `max`.
|
||||||
- The size is across **the whole tree**: a member that leads adds within the same `max` as the lead.
|
- The size is across **the whole tree**: a member that leads adds within the same `max` as the lead.
|
||||||
@@ -90,8 +110,9 @@ A stopped member does not count: stopping is how room is made.
|
|||||||
machinery (a hidden chat on LLeMbas with its `chat_jobs` row, a subagent job in the CLI), running
|
machinery (a hidden chat on LLeMbas with its `chat_jobs` row, a subagent job in the CLI), running
|
||||||
unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its
|
unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its
|
||||||
manager's (`permission/modes.json`).
|
manager's (`permission/modes.json`).
|
||||||
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only); `model` is
|
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only). **Its model**
|
||||||
as `task`'s — a model the person may use, the default being the caller's.
|
is the session's (the lead's) unless `model` gives one the person named in this session — as
|
||||||
|
`task`'s, refused with `subagents.texts` otherwise (`subagents.md` §2, §3).
|
||||||
- **Its tools** are those its agent gives a subagent, plus `team_send` and `team_read`; one added
|
- **Its tools** are those its agent gives a subagent, plus `team_send` and `team_read`; one added
|
||||||
with `lead: true` also has `team_add` and `team_stop`. They are fixed when it is made (§10 rule 4:
|
with `lead: true` also has `team_add` and `team_stop`. They are fixed when it is made (§10 rule 4:
|
||||||
a subagent's narrowing). `report` (`offered: subagent`) sends its manager a message before its
|
a subagent's narrowing). `report` (`offered: subagent`) sends its manager a message before its
|
||||||
@@ -108,7 +129,8 @@ A stopped member does not count: stopping is how room is made.
|
|||||||
`unavailable` (§2); `all_name` (the name `all`); `bad_name` (not `team.names.pattern`); `taken`
|
`unavailable` (§2); `all_name` (the name `all`); `bad_name` (not `team.names.pattern`); `taken`
|
||||||
(the name of an agent on the team, `lead` included); `no_goal`; `full`. Then the member is added:
|
(the name of an agent on the team, `lead` included); `no_goal`; `full`. Then the member is added:
|
||||||
`added`, or `added_waiting` when it has to wait for a slot (§8). An unknown `agent` or `model`
|
`added`, or `added_waiting` when it has to wait for a slot (§8). An unknown `agent` or `model`
|
||||||
answers as `task` does.
|
answers as `task` does; then `model`'s refusals, `not_named`, `same_group`, `one_request`
|
||||||
|
(`subagents.md`), in that order — before anything is added.
|
||||||
- **Members are not jobs to the model.** `job_list` does not show them and the `job_*` tools do not
|
- **Members are not jobs to the model.** `job_list` does not show them and the `job_*` tools do not
|
||||||
take them; the team tools do. (An implementation may run them on its job machinery.)
|
take them; the team tools do. (An implementation may run them on its job machinery.)
|
||||||
- **States**: `working` (a turn is running), `waiting` (it has work, and waits for a slot, §8),
|
- **States**: `working` (a turn is running), `waiting` (it has work, and waits for a slot, §8),
|
||||||
@@ -171,7 +193,7 @@ class.
|
|||||||
**The tool list does not change** when an agent becomes a manager (`context.md` §10, rule 4): the
|
**The tool list does not change** when an agent becomes a manager (`context.md` §10, rule 4): the
|
||||||
same tools are listed and the refused ones answer the refusal. An agent is a manager while it has
|
same tools are listed and the refused ones answer the refusal. An agent is a manager while it has
|
||||||
a member — a finished one included; one whose members were all stopped is not, unless it is the
|
a member — a finished one included; one whose members were all stopped is not, unless it is the
|
||||||
lead and `min ≥ 2` with the team offered. `task` stays: a one-off helper is delegation, and counts
|
lead and `min ≥ 2` with the team offered. `task` stays: a one-off subagent is delegation, and counts
|
||||||
toward the size (§3).
|
toward the size (§3).
|
||||||
|
|
||||||
## 7. Delivery, and waking
|
## 7. Delivery, and waking
|
||||||
@@ -180,7 +202,7 @@ A message or a report reaches its recipient **as a marked message** — the jobs
|
|||||||
(`loop.json` `jobs`): while the recipient's turn runs, as a user message at its next step boundary
|
(`loop.json` `jobs`): while the recipient's turn runs, as a user message at its next step boundary
|
||||||
(which does not start a turn: `context.md` §8); otherwise it **wakes** the recipient — a finished
|
(which does not start a turn: `context.md` §8); otherwise it **wakes** the recipient — a finished
|
||||||
member, or the idle lead — as a new turn of its own, marked `machine`, with its context intact, as
|
member, or the idle lead — as a new turn of its own, marked `machine`, with its context intact, as
|
||||||
`job_send` restarts a finished helper. A woken agent needs a slot (§8). Several waiting at one step
|
`job_send` restarts a finished subagent. A woken agent needs a slot (§8). Several waiting at one step
|
||||||
boundary go in one user message, in room order, separated by a blank line.
|
boundary go in one user message, in room order, separated by a blank line.
|
||||||
|
|
||||||
Every one begins with its mark, on a line of its own, then the text:
|
Every one begins with its mark, on a line of its own, then the text:
|
||||||
@@ -199,14 +221,16 @@ A session that is not open keeps them until it is resumed, as a job's note.
|
|||||||
## 8. Capacity: a member waits for a slot
|
## 8. Capacity: a member waits for a slot
|
||||||
|
|
||||||
A model serves `parallel` requests at once (`models/schema.json`). **An agent holds a slot of its
|
A model serves `parallel` requests at once (`models/schema.json`). **An agent holds a slot of its
|
||||||
model while its turn runs** — the lead, a member, a `task` helper. An agent with work (added, woken,
|
model while its turn runs** — the lead, a member, a `task` subagent. An agent with work (added, woken,
|
||||||
given a message while finished, the lead given the person's message) starts when its model has a
|
given a message while finished, the lead given the person's message) starts when its model has a
|
||||||
free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`.
|
free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`.
|
||||||
`team_add` is never refused for capacity (only for size): its answer says the member waits.
|
`team_add` is never refused for a full model (only for size, and for the model rules of
|
||||||
|
`subagents.md` §3: another model on the lead's one-model server, or a one-request session model):
|
||||||
|
its answer says the member waits.
|
||||||
|
|
||||||
Waiting agents start **in order**: the lead first (the person waits on it), then the others in the
|
Waiting agents start **in order**: the lead first (the person waits on it), then the others in the
|
||||||
order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own
|
order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own
|
||||||
rule (`loop.json` `capacity`: refused where its model is full), and its helper holds a slot.
|
rule (`loop.json` `capacity`: refused where its model is full), and its subagent holds a slot.
|
||||||
|
|
||||||
## 9. Stopping
|
## 9. Stopping
|
||||||
|
|
||||||
@@ -229,6 +253,10 @@ agent not under it (`not_yours`). The answer, `stopped`, names the stopped in tr
|
|||||||
|
|
||||||
## 11. Over the link
|
## 11. Over the link
|
||||||
|
|
||||||
|
*3.4.0: the tree and the room are carried — `acp.md` capability `tree`; what follows is how a device
|
||||||
|
without it is still shown.*
|
||||||
|
|
||||||
|
|
||||||
An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over
|
An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over
|
||||||
ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events,
|
ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events,
|
||||||
`description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown
|
`description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"subagent_run"
|
"subagent_run"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- Agents: {{agents}}",
|
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- It runs on your model. Another model only when the person named it in this conversation; nothing else chooses one.\n- Agents: {{agents}}",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"description": "Run it on another configured model (connection/model, from the list in your instructions) — a second opinion, or a model better at this kind of work. Default: the agent's own model, else yours.",
|
"description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: your model.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"cli": [],
|
"cli": [],
|
||||||
"llembas": []
|
"llembas": []
|
||||||
},
|
},
|
||||||
"description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}",
|
"description": "Add a subagent and start it at once, in the background, on its part of the work. Only you, the main agent, add subagents. You lead: subagents do the work while you plan, coordinate and review — once you have one, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`). `role` is its position, one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first subagent needs it, and every subagent is told it.\n- `lead: true` makes it a sub-manager: it directs the subagents you place under it (`parent`), reviews their reports and reports to you; it does not add or stop subagents itself.\n- `mode` is its permission mode: its parent's by default, never looser. With a stricter mode you may give `request` options — kinds of action (read, write, execute) it may ask you for with team_request; you decide with team_decide.\n- The person sets how many agents the team may have, you included; past that this is refused. When the model is already serving as many requests as it can at once, the subagent waits for a slot and starts when one frees.\n- It reports with team_finish (or its final reply). Meanwhile team_send messages it, team_read shows the tree and the room, team_assign gives it new work, team_replace replaces it, team_stop stops it.\n- Agents: {{agents}}",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "One line: what it owns"
|
"description": "Its position, one line: what it owns"
|
||||||
},
|
},
|
||||||
"task": {
|
"task": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
"lead": {
|
"lead": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "May add members of its own and lead an area (default: false)"
|
"description": "A sub-manager: directs the subagents placed under it (default: false)"
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -47,7 +47,33 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Run it on another model you may use (from the list in your instructions). Default: yours."
|
"description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: the session's model."
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The sub-manager to place it under (default: you, lead)"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"edit",
|
||||||
|
"manual",
|
||||||
|
"plan"
|
||||||
|
],
|
||||||
|
"description": "Its permission mode: its parent's or stricter (default: its parent's)"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"execute"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "With a stricter mode only: kinds of action it may ask you for (team_request)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -58,5 +84,6 @@
|
|||||||
},
|
},
|
||||||
"variables": {
|
"variables": {
|
||||||
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
|
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
|
||||||
}
|
},
|
||||||
|
"offered": "main"
|
||||||
}
|
}
|
||||||
@@ -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": [],
|
"cli": [],
|
||||||
"llembas": []
|
"llembas": []
|
||||||
},
|
},
|
||||||
"description": "The team and its room: every agent (name, role, who leads it, state, model, last activity), and the room's messages after `since` that are yours to see — those you sent or received and those of the members under you; the lead sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
|
"description": "The tree and its room: every agent (name, id, position, parent, state, model, mode, context use, last activity), open slots, subagents that have ended, and the room's messages after `since` that are yours to see — those you sent or received and those of the subagents under you; the main agent sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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": [],
|
"cli": [],
|
||||||
"llembas": []
|
"llembas": []
|
||||||
},
|
},
|
||||||
"description": "Send a message through your team's room: to one or more agents by name, or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The team's lead is `lead`; your manager is reached by its name.",
|
"description": "Send a message through the team's room: to one or more agents by name, to `parent` (the agent you report to), or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The main agent is `lead`.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"description": "Names, or [\"all\"]"
|
"description": "Names, [\"parent\"], or [\"all\"]"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -14,18 +14,19 @@
|
|||||||
"cli": [],
|
"cli": [],
|
||||||
"llembas": []
|
"llembas": []
|
||||||
},
|
},
|
||||||
"description": "Stop a member of your team, and the members under it, where they are: their work stops and they leave the team, which makes room for others. Only members under you, directly or through your members; `all` stops every one of them. Nothing they had not reported comes back — ask for a report with team_send first when you need it.",
|
"description": "Stop a subagent, and the subagents under it, where they are: their work stops and they end, which makes room for others. Only you, the main agent, stop subagents; `all` stops every one. Nothing they had not reported comes back — ask for a report with team_send first, or read their findings with team_findings.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "A member under you, or all"
|
"description": "A subagent's name, or all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"name"
|
"name"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"offered": "main"
|
||||||
}
|
}
|
||||||
@@ -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
@@ -324,5 +324,5 @@ say ""
|
|||||||
say "Done. Open a new shell (or: source your rc file), then run: lembas"
|
say "Done. Open a new shell (or: source your rc file), then run: lembas"
|
||||||
[ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')"
|
[ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')"
|
||||||
if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then
|
if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then
|
||||||
say "Next: add a connection to $CONFIG/connections.yaml and set model: in config.yaml."
|
say "Next: lembas login <your LLeMbas server address>"
|
||||||
fi
|
fi
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lembas-cli",
|
"name": "lembas-cli",
|
||||||
"version": "2.0.0-beta.3",
|
"version": "2.0.0-beta.6",
|
||||||
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -894,17 +894,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.",
|
"description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {
|
"min": {
|
||||||
"description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.",
|
"description": "Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
},
|
},
|
||||||
"max": {
|
"max": {
|
||||||
"description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.",
|
"description": "Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
|
|||||||
@@ -210,11 +210,11 @@
|
|||||||
"maxLength": 300
|
"maxLength": 300
|
||||||
},
|
},
|
||||||
"single_session": {
|
"single_session": {
|
||||||
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.",
|
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"parallel": {
|
"parallel": {
|
||||||
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.",
|
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
|
|||||||
+49
-2
@@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
|
|||||||
import type { Message } from "../provider/types.ts"
|
import type { Message } from "../provider/types.ts"
|
||||||
import type { Hub } from "./hub.ts"
|
import type { Hub } from "./hub.ts"
|
||||||
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import { isNote } from "../session/prefix.ts"
|
import { isNote } from "../session/prefix.ts"
|
||||||
|
|
||||||
export const ACP_VERSION = 1
|
export const ACP_VERSION = 1
|
||||||
@@ -273,6 +273,12 @@ export const CAPABILITIES = [
|
|||||||
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
||||||
// said it can show files; the device sends the file with `_lembas/show`.
|
// said it can show files; the device sends the file with `_lembas/show`.
|
||||||
"show",
|
"show",
|
||||||
|
// Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent
|
||||||
|
// tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's
|
||||||
|
// Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's).
|
||||||
|
"progress",
|
||||||
|
"tree",
|
||||||
|
"agents",
|
||||||
] as const
|
] as const
|
||||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
||||||
|
|
||||||
@@ -306,6 +312,8 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
|
|||||||
...(o.patient ? { patient: true } : {}),
|
...(o.patient ? { patient: true } : {}),
|
||||||
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
||||||
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
||||||
|
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
|
||||||
|
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -468,6 +476,10 @@ export class AcpAgent {
|
|||||||
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
||||||
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
||||||
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
||||||
|
peer.handle("_lembas/team", (p) => this.tree("team", p))
|
||||||
|
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
|
||||||
|
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
|
||||||
|
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
|
||||||
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
||||||
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
||||||
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
||||||
@@ -675,6 +687,8 @@ export class AcpAgent {
|
|||||||
const effort = effortOf(p?.effort)
|
const effort = effortOf(p?.effort)
|
||||||
// A level the model does not offer is read as its start level; the answer says the one used.
|
// A level the model does not offer is read as its start level; the answer says the one used.
|
||||||
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
||||||
|
// Spec 3.4.0: the chat's Subagents setting, changed between turns.
|
||||||
|
if (p?.agents) this.takeAgents(s, { agents: p.agents })
|
||||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +738,19 @@ export class AcpAgent {
|
|||||||
private takeContext(s: Session, p: any) {
|
private takeContext(s: Session, p: any) {
|
||||||
const ctx = contextOf(p?._meta)
|
const ctx = contextOf(p?._meta)
|
||||||
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
||||||
|
this.takeAgents(s, p?._meta?.lembas)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the
|
||||||
|
* instance — absent (an older one, an editor on serve --stdio): the device's own, as before.
|
||||||
|
* `said`: the person's earlier words in the chat. */
|
||||||
|
private takeAgents(s: Session, m: any) {
|
||||||
|
if (!this.lembas) return
|
||||||
|
const team = s.app.team.team
|
||||||
|
const a = m?.agents
|
||||||
|
if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max)
|
||||||
|
if (m?.subagents === false) team.subagents = false
|
||||||
|
if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
||||||
@@ -1067,6 +1094,8 @@ export class AcpAgent {
|
|||||||
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
||||||
plan: s.app.engine.plan ?? null,
|
plan: s.app.engine.plan ?? null,
|
||||||
jobs: jobList(s.app),
|
jobs: jobList(s.app),
|
||||||
|
progress: s.app.engine.progress.current,
|
||||||
|
tree: treeOf(s.app),
|
||||||
}
|
}
|
||||||
const row = (this.store ??= new Store()).session(id)
|
const row = (this.store ??= new Store()).session(id)
|
||||||
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
||||||
@@ -1091,6 +1120,24 @@ export class AcpAgent {
|
|||||||
return { jobs: jobList(this.get(p).app) }
|
return { jobs: jobList(this.get(p).app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the
|
||||||
|
* session's subagent tree — the terminal's through the hub, as the jobs go. */
|
||||||
|
private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) {
|
||||||
|
const t = this.terminalFor(p)
|
||||||
|
if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000)
|
||||||
|
const app = this.get(p).app
|
||||||
|
switch (op) {
|
||||||
|
case "team":
|
||||||
|
return teamView(app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(app, p?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
||||||
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
||||||
const t = this.terminalFor(p)
|
const t = this.terminalFor(p)
|
||||||
@@ -1291,7 +1338,7 @@ export class AcpAgent {
|
|||||||
private forward(s: Session, e: Event) {
|
private forward(s: Session, e: Event) {
|
||||||
// A model change says whether the model is this link's instance's: its ref is the
|
// A model change says whether the model is this link's instance's: its ref is the
|
||||||
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
||||||
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e
|
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e
|
||||||
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { App } from "../app.ts"
|
import type { App } from "../app.ts"
|
||||||
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
||||||
import type { Store } from "../session/store.ts"
|
import type { Store } from "../session/store.ts"
|
||||||
|
import type { TreeInfo } from "../team/team.ts"
|
||||||
import { historyTurns } from "./agent.ts"
|
import { historyTurns } from "./agent.ts"
|
||||||
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
||||||
|
|
||||||
@@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] {
|
|||||||
}
|
}
|
||||||
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ────────────────────
|
||||||
|
|
||||||
|
/** The tree (subagents.md §12), or null for a session with no subagents. */
|
||||||
|
export function treeOf(app: App): TreeInfo | null {
|
||||||
|
const t = app.team.team
|
||||||
|
if (!t.offered && !t.hasMembers() && !t.ended.length) return null
|
||||||
|
return t.info()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`: the tree and the whole room above `since`. */
|
||||||
|
export function teamView(app: App, since: unknown) {
|
||||||
|
const n = typeof since === "number" && since >= 0 ? since : 0
|
||||||
|
const t = app.team.team
|
||||||
|
return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentOf(app: App, id: unknown) {
|
||||||
|
const key = String(id ?? "")
|
||||||
|
const a = app.team.team.find(key)
|
||||||
|
if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/transcript`: a subagent's session, read-only. */
|
||||||
|
export function teamTranscript(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined
|
||||||
|
const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : [])
|
||||||
|
return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/findings`. */
|
||||||
|
export function teamFindings(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
return { id: a.id, findings: a.findings.map((f) => ({ ...f })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */
|
||||||
|
export function teamStop(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`)
|
||||||
|
const gone = app.team.personStop(a.name)
|
||||||
|
return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) }
|
||||||
|
}
|
||||||
+11
-2
@@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
|||||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||||
import { readLevel } from "../provider/effort.ts"
|
import { readLevel } from "../provider/effort.ts"
|
||||||
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import type { SessionContext } from "../app.ts"
|
import type { SessionContext } from "../app.ts"
|
||||||
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
||||||
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
||||||
@@ -454,7 +454,7 @@ export class Share {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
||||||
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e })
|
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e })
|
||||||
if (e.type === "task" && e.state === "end") this.sendCommands()
|
if (e.type === "task" && e.state === "end") this.sendCommands()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,6 +520,15 @@ export class Share {
|
|||||||
return jobSend(this.app, p?.id, p?.text)
|
return jobSend(this.app, p?.id, p?.text)
|
||||||
case "transcript":
|
case "transcript":
|
||||||
return jobTranscript(this.app, p?.id)
|
return jobTranscript(this.app, p?.id)
|
||||||
|
// Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go.
|
||||||
|
case "team":
|
||||||
|
return teamView(this.app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(this.app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(this.app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(this.app, p?.id)
|
||||||
default:
|
default:
|
||||||
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-23
@@ -16,6 +16,7 @@ import type { Mode } from "./config/schema.ts"
|
|||||||
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
||||||
import { hardlineRules } from "./permission/hardline.ts"
|
import { hardlineRules } from "./permission/hardline.ts"
|
||||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||||
|
import { subagentModel, type ModelInfo } from "./team/subagents.ts"
|
||||||
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||||
import type { Changing } from "./session/prefix.ts"
|
import type { Changing } from "./session/prefix.ts"
|
||||||
import { describeRepo, repoState } from "./git/repo.ts"
|
import { describeRepo, repoState } from "./git/repo.ts"
|
||||||
@@ -45,7 +46,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
|||||||
import type { McpServer } from "./config/schema.ts"
|
import type { McpServer } from "./config/schema.ts"
|
||||||
import { embedderFor } from "./library/embed.ts"
|
import { embedderFor } from "./library/embed.ts"
|
||||||
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
|
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
|
||||||
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
|
import { memberTools as memberToolsOf, readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
|
||||||
|
import { whoDecides } from "./team/delegation.ts"
|
||||||
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
|
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
@@ -202,8 +204,15 @@ function openInBrowser(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A model as the subagent rules see it (subagents.md): its names and what its server can do at once. */
|
||||||
|
function modelInfo(m: ResolvedModel): ModelInfo {
|
||||||
|
return { ref: m.ref, id: m.id, name: m.spec.name, capacity: { parallel: m.spec.parallel, single_session: m.spec.single_session, group: m.spec.group } }
|
||||||
|
}
|
||||||
|
|
||||||
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
|
/** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */
|
||||||
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
|
export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name))
|
||||||
|
/** Spec 3.4.0: tools the spec offers to the main agent only (`offered: main`): never a subagent's. */
|
||||||
|
export const MAIN_ONLY_TOOLS = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "main").map((t) => t.name))
|
||||||
|
|
||||||
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
||||||
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
|
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
|
||||||
@@ -364,7 +373,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
|
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
|
||||||
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
|
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
|
||||||
}
|
}
|
||||||
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
|
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }) => {
|
||||||
const src = ch.source as Source
|
const src = ch.source as Source
|
||||||
return assembleSystem({
|
return assembleSystem({
|
||||||
modelRef: current.ref,
|
modelRef: current.ref,
|
||||||
@@ -473,13 +482,22 @@ export function createApp(o: AppOptions): App {
|
|||||||
// its server can take it beside the session's and beside every subagent already running
|
// its server can take it beside the session's and beside every subagent already running
|
||||||
// (src/session/capacity.ts), before anything is made.
|
// (src/session/capacity.ts), before anything is made.
|
||||||
const member = opts.member
|
const member = opts.member
|
||||||
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
// Spec 3.3.0 (subagents.md): the session's model, unless the person named another; an agent
|
||||||
|
// file's own `model` is not used. A member's model was judged by team_add.
|
||||||
|
let m: ResolvedModel
|
||||||
|
if (member) m = opts.model ? resolveModel(loaded, opts.model) : engine.model
|
||||||
|
else {
|
||||||
|
const wanted = opts.model ? resolveModel(loaded, opts.model) : undefined
|
||||||
|
const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid())
|
||||||
|
if ("refused" in r) throw new ToolError(r.refused)
|
||||||
|
m = wanted && wanted.ref === r.model ? wanted : engine.model
|
||||||
|
}
|
||||||
// A team's member is not refused for capacity: the team gave it its slot (team.md §8).
|
// A team's member is not refused for capacity: the team gave it its slot (team.md §8).
|
||||||
if (!member) {
|
if (!member) {
|
||||||
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
|
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
|
||||||
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
|
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
|
||||||
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
|
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
|
||||||
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
|
if (refused) throw new ToolError(`${refused} Wait for one to finish, or do this part yourself.`)
|
||||||
}
|
}
|
||||||
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||||
let wt: Worktree | undefined
|
let wt: Worktree | undefined
|
||||||
@@ -508,13 +526,17 @@ export function createApp(o: AppOptions): App {
|
|||||||
}
|
}
|
||||||
: perm
|
: perm
|
||||||
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
||||||
// A team member also has team_send and team_read, and one that may lead team_add and team_stop
|
// A subagent of the tree also has its team tools (spec 3.4.0 §8: whoami, read, send, findings,
|
||||||
// (team.md §4); a task helper has none of them.
|
// finish, and request with request options) — never the main agent's (`offered: main`); a task
|
||||||
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
|
// subagent has none of them.
|
||||||
|
const memberTools = member ? memberToolsOf(member) : []
|
||||||
|
const isTeamTool = (n: string) => (TEAM_TOOLS as readonly string[]).includes(n)
|
||||||
const childTools = [
|
const childTools = [
|
||||||
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
|
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && !MAIN_ONLY_TOOLS.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
|
||||||
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)),
|
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name) && (!isTeamTool(t.name) || memberTools.includes(t.name))),
|
||||||
]
|
]
|
||||||
|
// Its mode: the one the main agent gave it, made stricter by its agent's own (explore reads).
|
||||||
|
if (member) member.mode = stricterMode(spec.mode, member.mode)
|
||||||
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
|
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
|
||||||
const ownerSession = jobsSessionId()
|
const ownerSession = jobsSessionId()
|
||||||
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
||||||
@@ -524,7 +546,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
let job: Job
|
let job: Job
|
||||||
// The asker is the session's; a child's ask says which subagent it comes from.
|
// The asker is the session's; a child's ask says which subagent it comes from.
|
||||||
const askerFor = (): Asker => ({
|
const askerFor = (): Asker => ({
|
||||||
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }),
|
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }),
|
||||||
get question() {
|
get question() {
|
||||||
return o.asker.question
|
return o.asker.question
|
||||||
},
|
},
|
||||||
@@ -540,9 +562,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
tools: childTools,
|
tools: childTools,
|
||||||
store,
|
store,
|
||||||
sessionId: childRow?.id,
|
sessionId: childRow?.id,
|
||||||
// An agent's own mode can make it stricter than its parent, never looser.
|
// An agent's own mode can make it stricter than its parent, never looser. A subagent of the
|
||||||
// A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode.
|
// tree runs in the mode the main agent gave it (spec 3.4.0 §7), set above.
|
||||||
mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode),
|
mode: member ? member.mode : stricterMode(spec.mode, engine.mode),
|
||||||
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
|
||||||
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
|
||||||
compaction,
|
compaction,
|
||||||
@@ -559,6 +581,13 @@ export function createApp(o: AppOptions): App {
|
|||||||
fix: () => teamRt.team.offered,
|
fix: () => teamRt.team.offered,
|
||||||
offered: () => teamRt.team.offered,
|
offered: () => teamRt.team.offered,
|
||||||
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
|
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
|
||||||
|
// Spec 3.4.0 §7: itself, the main agent through a request, or the person.
|
||||||
|
permit: (request, ctx) => {
|
||||||
|
const a = teamRt.team.get(member.name)
|
||||||
|
return whoDecides(request, { ctx, mode: a?.mode ?? member.mode, main: engine.mode, request: a?.request ?? [], grants: a?.grants ?? [], unattended: o.unattended })
|
||||||
|
},
|
||||||
|
contextFull: (used: number, window: number) => (window ? teamRt.contextFull(member.name) : "go"),
|
||||||
|
ended: () => teamRt.hasEnded(member.name),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -581,7 +610,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
plan,
|
plan,
|
||||||
member ? { name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
|
member ? { id: member.id, name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
// Its own background commands wake it at its next step while it runs; once it has ended they
|
// Its own background commands wake it at its next step while it runs; once it has ended they
|
||||||
@@ -597,14 +626,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
||||||
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
||||||
}
|
}
|
||||||
// A task helper started inside a team counts toward its size and holds a slot while it runs
|
// A task subagent started inside a team counts toward its size and holds a slot while it runs
|
||||||
// (team.md §3, §8).
|
// (team.md §3, §8).
|
||||||
const counted = (p: Promise<SubagentResult>) => {
|
const counted = (p: Promise<SubagentResult>) => {
|
||||||
if (member || !teamRt.team.offered) return p
|
if (member || !teamRt.team.offered) return p
|
||||||
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
|
const id = teamRt.team.taskStart(TEAM.names.lead, m.ref)
|
||||||
return p.finally(() => teamRt.team.helperDone(id))
|
return p.finally(() => teamRt.team.taskDone(id))
|
||||||
}
|
}
|
||||||
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
|
const run = (text: string, machine = false, steps?: number) => counted(child.prompt(text, [], text, { ...(machine ? { machine: true as const } : {}), ...(steps ? { maxSteps: steps } : {}) }).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
|
||||||
const handle: SubagentHandle = {
|
const handle: SubagentHandle = {
|
||||||
bus: childBus,
|
bus: childBus,
|
||||||
engine: child,
|
engine: child,
|
||||||
@@ -616,15 +645,26 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (member) {
|
if (member) {
|
||||||
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
|
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
|
||||||
job.team = member.name
|
job.team = member.name
|
||||||
const memberRun = (text: string, machine: boolean) => {
|
member.jobId = job.id
|
||||||
|
// Its progress and context use, for the tree (spec 3.4.0 §12).
|
||||||
|
childBus.on((e) => {
|
||||||
|
if (e.type === "phase") {
|
||||||
|
teamRt.team.setProgress(member.name, e.progress)
|
||||||
|
teamRt.notify()
|
||||||
|
} else if (e.type === "usage" && !e.child && e.context) {
|
||||||
|
teamRt.team.setContext(member.name, e.used ?? e.usage.input + e.usage.output, e.context)
|
||||||
|
teamRt.notify()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const memberRun = (text: string, machine: boolean, steps?: number) => {
|
||||||
if (job.state !== "running") registry.restartSubagent(job)
|
if (job.state !== "running") registry.restartSubagent(job)
|
||||||
void run(text, machine).then((r) => {
|
void run(text, machine, steps).then((r) => {
|
||||||
const leftover = [...child.takeNotes(), ...child.takeInbox()]
|
const leftover = [...child.takeNotes(), ...child.takeInbox()]
|
||||||
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
|
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
|
||||||
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
|
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel() }
|
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel(), final: (text) => memberRun(text, true, TEAM.context.final_steps) }
|
||||||
teamRt.attach(member.name, mh)
|
teamRt.attach(member.name, mh)
|
||||||
memberRun(prompt, false)
|
memberRun(prompt, false)
|
||||||
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
|
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
|
||||||
@@ -683,6 +723,8 @@ export function createApp(o: AppOptions): App {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Every model the person may use: the server's, as the login read them (subagents.md §4).
|
||||||
|
const usableModels = () => loaded.refs.map((r) => r.ref)
|
||||||
// The size as set while the process runs (any scope): a new or resumed session keeps it.
|
// The size as set while the process runs (any scope): a new or resumed session keeps it.
|
||||||
const teamSize: { min?: number; max?: number } = {}
|
const teamSize: { min?: number; max?: number } = {}
|
||||||
const newTeam = () =>
|
const newTeam = () =>
|
||||||
@@ -693,8 +735,11 @@ export function createApp(o: AppOptions): App {
|
|||||||
subagents: loaded.config.subagents !== false,
|
subagents: loaded.config.subagents !== false,
|
||||||
model: engine.model.ref,
|
model: engine.model.ref,
|
||||||
capacity: capacityOf,
|
capacity: capacityOf,
|
||||||
|
models: usableModels,
|
||||||
leadState: "idle",
|
leadState: "idle",
|
||||||
readingBash: (command) => readingBashByDefault(command, project.root),
|
readingBash: (command) => readingBashByDefault(command, project.root),
|
||||||
|
mode: engine.mode,
|
||||||
|
steps: { main: loaded.config.limits?.steps ?? LOOP.steps.default, sub: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default },
|
||||||
})
|
})
|
||||||
const teamRt: TeamRuntime = new TeamRuntime({
|
const teamRt: TeamRuntime = new TeamRuntime({
|
||||||
team: newTeam(),
|
team: newTeam(),
|
||||||
@@ -707,11 +752,19 @@ export function createApp(o: AppOptions): App {
|
|||||||
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
|
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) },
|
lead: {
|
||||||
|
note: (text) => engine.note(text),
|
||||||
|
wake: (text) => wake(text, shownLine(text)),
|
||||||
|
waiting: (model) => engine.progress.set({ phase: "waiting_for_model", model: engine.model.spec.name ?? model, reason: "slots" }),
|
||||||
|
},
|
||||||
|
tools: () => tools,
|
||||||
resolve: (agent, model) => {
|
resolve: (agent, model) => {
|
||||||
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
||||||
try {
|
try {
|
||||||
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref }
|
// Spec 3.3.0 (subagents.md): the session's model, unless the person named another.
|
||||||
|
const wanted = model ? resolveModel(loaded, model) : undefined
|
||||||
|
const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid())
|
||||||
|
return "refused" in r ? { error: r.refused } : { model: r.model }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: (e as Error).message }
|
return { error: (e as Error).message }
|
||||||
}
|
}
|
||||||
@@ -719,6 +772,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
})
|
})
|
||||||
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
|
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
|
||||||
bus.on((e) => e.type === "done" && teamRt.leadDone())
|
bus.on((e) => e.type === "done" && teamRt.leadDone())
|
||||||
|
// Spec 3.4.0: the main agent's mode reaches down the tree; its progress and context are in it.
|
||||||
|
bus.on((e) => {
|
||||||
|
if (e.type === "mode") teamRt.mainMode(e.mode)
|
||||||
|
else if (e.type === "phase") {
|
||||||
|
teamRt.team.setProgress(TEAM.names.lead, e.progress)
|
||||||
|
if (teamRt.team.hasMembers()) teamRt.notify()
|
||||||
|
} else if (e.type === "usage" && !e.child && e.context) teamRt.team.setContext(TEAM.names.lead, e.used ?? e.usage.input + e.usage.output, e.context)
|
||||||
|
})
|
||||||
|
|
||||||
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
||||||
let mcpNames = new Set<string>()
|
let mcpNames = new Set<string>()
|
||||||
|
|||||||
+5
-2
@@ -47,7 +47,7 @@ export type Event =
|
|||||||
| { type: "steered"; texts: string[]; ids?: string[] }
|
| { type: "steered"; texts: string[]; ids?: string[] }
|
||||||
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
|
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
|
||||||
| { type: "task"; state: "start" | "end"; turnId?: string }
|
| { type: "task"; state: "start" | "end"; turnId?: string }
|
||||||
| { type: "notice"; message: string; level?: "info" | "warn" }
|
| { type: "notice"; message: string; level?: "info" | "warn"; /** Spec 3.4.0: a request that will be sent again. */ retry?: { reason: string; in: number; attempt?: number } }
|
||||||
/** Discard the reply streamed so far in this step: it is being retried. */
|
/** Discard the reply streamed so far in this step: it is being retried. */
|
||||||
| { type: "retract" }
|
| { type: "retract" }
|
||||||
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
|
/** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */
|
||||||
@@ -65,6 +65,9 @@ export type Event =
|
|||||||
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
|
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
|
||||||
* message in the room. Read the team itself for what. */
|
* message in the room. Read the team itself for what. */
|
||||||
| { type: "team" }
|
| { type: "team" }
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what the session's agent is doing now, or null. A
|
||||||
|
* subagent's progress is in the tree (`team`). */
|
||||||
|
| { type: "phase"; progress: import("../session/progress.ts").Progress | null }
|
||||||
/** The permission mode changed from inside a turn (plan approved). */
|
/** The permission mode changed from inside a turn (plan approved). */
|
||||||
| { type: "mode"; mode: Mode }
|
| { type: "mode"; mode: Mode }
|
||||||
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
||||||
@@ -127,7 +130,7 @@ export interface Asker {
|
|||||||
purpose?: string
|
purpose?: string
|
||||||
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
||||||
* ask while the session itself is idle. */
|
* ask while the session itself is idle. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
}): Promise<AskReply>
|
}): Promise<AskReply>
|
||||||
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
||||||
* id, which keys the card on another screen. */
|
* id, which keys the card on another screen. */
|
||||||
|
|||||||
+10
-1
@@ -98,7 +98,7 @@ function fail(msg: string, code = 1): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
||||||
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError } = await import("./lembas/login.ts")
|
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError, SignInRejected } = await import("./lembas/login.ts")
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args,
|
args,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -144,10 +144,19 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
|||||||
// `login` again with no address refreshes the one instance there is.
|
// `login` again with no address refreshes the one instance there is.
|
||||||
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
|
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
|
||||||
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
||||||
|
try {
|
||||||
const r = await sync(again.connection, { setDefault: values.default })
|
const r = await sync(again.connection, { setDefault: values.default })
|
||||||
if (json) emit(doneEvent(r, false))
|
if (json) emit(doneEvent(r, false))
|
||||||
else console.log(loginSummary(r, false).join("\n"))
|
else console.log(loginSummary(r, false).join("\n"))
|
||||||
return
|
return
|
||||||
|
} catch (e) {
|
||||||
|
// The token here was revoked or is unknown to the instance: refreshing it can never work,
|
||||||
|
// and "run login again" would send the person straight back here. Sign in afresh instead;
|
||||||
|
// the new login replaces the old one.
|
||||||
|
if (!(e instanceof SignInRejected)) throw e
|
||||||
|
if (!json) console.error("The instance no longer accepts the sign-in kept here: signing in again.")
|
||||||
|
address = again.base_url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
||||||
// again, at the address it had.
|
// again, at the address it had.
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export const ModelSpec = z.strictObject({
|
|||||||
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
|
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
|
||||||
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
|
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
|
||||||
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
|
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
|
||||||
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."),
|
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1."),
|
||||||
parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."),
|
parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2."),
|
||||||
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
|
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
|
||||||
})
|
})
|
||||||
export type ModelSpec = z.infer<typeof ModelSpec>
|
export type ModelSpec = z.infer<typeof ModelSpec>
|
||||||
@@ -298,12 +298,12 @@ export const Config = z.strictObject({
|
|||||||
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
|
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
|
||||||
agents: z
|
agents: z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."),
|
min: z.number().int().min(1).optional().describe("Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start."),
|
||||||
max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."),
|
max: z.number().int().min(1).optional().describe("Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents."),
|
||||||
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
|
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."),
|
.describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session."),
|
||||||
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
|
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
|
||||||
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
|
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
|
||||||
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { parse, parseDocument } from "yaml"
|
import { parse, parseDocument } from "yaml"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import LOOP_JSON from "../../harness/loop.json"
|
||||||
import { paths } from "./paths.ts"
|
import { paths } from "./paths.ts"
|
||||||
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
|
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
|
||||||
|
|
||||||
@@ -29,8 +30,13 @@ export interface SettingDef {
|
|||||||
reload?: boolean
|
reload?: boolean
|
||||||
/** What it is when nothing sets it. */
|
/** What it is when nothing sets it. */
|
||||||
fallback?: string
|
fallback?: string
|
||||||
|
/** What a person reads beside the key in /settings. */
|
||||||
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Subagents setting's names and what its numbers count (harness loop.json subagents.setting).
|
||||||
|
const SUB = LOOP_JSON.subagents.setting
|
||||||
|
|
||||||
const ALL: Scope[] = ["session", "global", "project"]
|
const ALL: Scope[] = ["session", "global", "project"]
|
||||||
const USER: Scope[] = ["session", "global"]
|
const USER: Scope[] = ["session", "global"]
|
||||||
|
|
||||||
@@ -38,8 +44,8 @@ export const SETTINGS: SettingDef[] = [
|
|||||||
{ key: "model", kind: "model", agent: true, scopes: ALL },
|
{ key: "model", kind: "model", agent: true, scopes: ALL },
|
||||||
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
|
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
|
||||||
// The team's size is the person's: the agent never sets how many agents it may have.
|
// The team's size is the person's: the agent never sets how many agents it may have.
|
||||||
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" },
|
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1", hint: `${SUB.label}: ${SUB.min} — ${SUB.short}` },
|
||||||
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" },
|
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4", hint: `${SUB.label}: ${SUB.max} — this agent included; 4 is this agent and up to 3 subagents` },
|
||||||
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
|
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
|
||||||
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
|
||||||
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
|
||||||
|
|||||||
+63
-7
@@ -52,6 +52,13 @@ import t_team_add from "../harness/tools/team_add.json"
|
|||||||
import t_team_read from "../harness/tools/team_read.json"
|
import t_team_read from "../harness/tools/team_read.json"
|
||||||
import t_team_send from "../harness/tools/team_send.json"
|
import t_team_send from "../harness/tools/team_send.json"
|
||||||
import t_team_stop from "../harness/tools/team_stop.json"
|
import t_team_stop from "../harness/tools/team_stop.json"
|
||||||
|
import t_team_assign from "../harness/tools/team_assign.json"
|
||||||
|
import t_team_decide from "../harness/tools/team_decide.json"
|
||||||
|
import t_team_findings from "../harness/tools/team_findings.json"
|
||||||
|
import t_team_finish from "../harness/tools/team_finish.json"
|
||||||
|
import t_team_replace from "../harness/tools/team_replace.json"
|
||||||
|
import t_team_request from "../harness/tools/team_request.json"
|
||||||
|
import t_team_whoami from "../harness/tools/team_whoami.json"
|
||||||
import t_view_image from "../harness/tools/view_image.json"
|
import t_view_image from "../harness/tools/view_image.json"
|
||||||
import t_web_fetch from "../harness/tools/web_fetch.json"
|
import t_web_fetch from "../harness/tools/web_fetch.json"
|
||||||
import t_web_search from "../harness/tools/web_search.json"
|
import t_web_search from "../harness/tools/web_search.json"
|
||||||
@@ -76,8 +83,9 @@ export interface ToolSpecDef {
|
|||||||
purpose: string | false
|
purpose: string | false
|
||||||
/** Names each project used before spec v1; a call under one is read as this tool. */
|
/** Names each project used before spec v1; a call under one is read as this tool. */
|
||||||
aliases: { cli: string[]; llembas: string[] }
|
aliases: { cli: string[]; llembas: string[] }
|
||||||
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
|
/** `subagent`: offered only to a subagent (report, team_finish, team_request), never to a
|
||||||
offered?: "subagent"
|
* session's own model; `main` (spec 3.4.0): only to the main agent, never to a subagent. */
|
||||||
|
offered?: "subagent" | "main"
|
||||||
argument_aliases?: Record<string, string>
|
argument_aliases?: Record<string, string>
|
||||||
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
|
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
|
||||||
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
|
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
|
||||||
@@ -118,16 +126,62 @@ export const LOOP: {
|
|||||||
running_max: { default: number }
|
running_max: { default: number }
|
||||||
subagent_steps: { default: number }
|
subagent_steps: { default: number }
|
||||||
}
|
}
|
||||||
|
/** Subagents (spec 3.3.0, harness/subagents.md): the model refusals, what names a model, and how
|
||||||
|
* the size setting is shown. */
|
||||||
|
subagents: {
|
||||||
|
texts: { not_named: string; same_group: string; one_request: string }
|
||||||
|
named: { before: string; after: string }
|
||||||
|
setting: { label: string; min: string; max: string; hint: string; short: string; own: string }
|
||||||
|
}
|
||||||
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
||||||
team: {
|
team: {
|
||||||
agents: { min: number; max: number; cap: number }
|
agents: { min: number; max: number; cap: number }
|
||||||
names: { pattern: string; lead: string; reserved: string[] }
|
names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
|
||||||
marks: { message: string; report: string }
|
marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
|
||||||
unavailable: { one_agent: string; one_request: string }
|
unavailable: { one_agent: string; one_request: string }
|
||||||
work: { write: string[]; execute: string[]; kept: string[] }
|
work: { write: string[]; execute: string[]; kept: string[] }
|
||||||
read_max: number
|
read_max: number
|
||||||
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string>
|
texts: Record<
|
||||||
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string }
|
| "added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
|
||||||
|
// Spec 3.4.0 (subagents.md §5–§13).
|
||||||
|
| "only_main" | "not_manager" | "wider" | "request_not_narrower" | "request_kind" | "requestable" | "not_delegated" | "not_requestable" | "requested" | "no_request" | "decided" | "allowed" | "refused" | "decision_allowed" | "decision_refused" | "no_parent" | "finished" | "has_ended" | "cycle" | "assigned" | "replaced" | "replace_brief" | "no_findings" | "noted" | "not_yours_findings" | "context_full" | "gone" | "request_body",
|
||||||
|
string
|
||||||
|
>
|
||||||
|
read: {
|
||||||
|
tree: string
|
||||||
|
agent: string
|
||||||
|
lead_role: string
|
||||||
|
states: Record<"working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped", string>
|
||||||
|
active: string
|
||||||
|
room: string
|
||||||
|
message: string
|
||||||
|
report: string
|
||||||
|
none: string
|
||||||
|
more: string
|
||||||
|
slots: string
|
||||||
|
ended: string
|
||||||
|
ended_agent: string
|
||||||
|
context: string
|
||||||
|
request: string
|
||||||
|
}
|
||||||
|
/** Spec 3.4.0. */
|
||||||
|
main_only: { tools: string[] }
|
||||||
|
context: { pause_at: number; final_steps: number }
|
||||||
|
request: { kinds: string[]; id_prefix: string }
|
||||||
|
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
|
||||||
|
findings: { header: string; item: string }
|
||||||
|
}
|
||||||
|
/** Progress (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
|
||||||
|
progress: {
|
||||||
|
phases: string[]
|
||||||
|
texts: Record<string, string>
|
||||||
|
reason: Record<string, string>
|
||||||
|
what: Record<string, string>
|
||||||
|
stopped_reason: Record<string, string>
|
||||||
|
line: string
|
||||||
|
no_elapsed: string[]
|
||||||
|
what_max: number
|
||||||
|
pace_per_second: number
|
||||||
}
|
}
|
||||||
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
|
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
|
||||||
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
||||||
@@ -165,6 +219,8 @@ export const LOOP: {
|
|||||||
team_lead: string
|
team_lead: string
|
||||||
team_one: string
|
team_one: string
|
||||||
team_stopped: string
|
team_stopped: string
|
||||||
|
/** Spec 3.3.0 (subagents.md §4): the session's model cannot serve subagents itself. */
|
||||||
|
team_own: string
|
||||||
}
|
}
|
||||||
/** How long a session waits for its configured MCP servers before its first request. */
|
/** How long a session waits for its configured MCP servers before its first request. */
|
||||||
mcp_wait_ms: number
|
mcp_wait_ms: number
|
||||||
@@ -245,7 +301,7 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
|
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
|
||||||
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_read, t_team_send, t_team_stop, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_assign, t_team_decide, t_team_findings, t_team_finish, t_team_read, t_team_replace, t_team_request, t_team_send, t_team_stop, t_team_whoami, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
|
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function protocolMismatch(base: string, d: Pick<Discovery, "protocol" | "
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LembasError extends Error {}
|
export class LembasError extends Error {}
|
||||||
|
/** The instance answered 401: the token this machine holds is revoked or unknown there. `login` signs in afresh. */
|
||||||
|
export class SignInRejected extends LembasError {}
|
||||||
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
|
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
|
||||||
export class ProtocolError extends LembasError {}
|
export class ProtocolError extends LembasError {}
|
||||||
|
|
||||||
@@ -268,7 +270,7 @@ export async function checkToken(d: Discovery, token: string, tls: Tls | undefin
|
|||||||
|
|
||||||
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
|
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
|
||||||
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)")
|
if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)")
|
||||||
if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json()) as { data?: ServedModel[] }
|
const body = (await res.json()) as { data?: ServedModel[] }
|
||||||
@@ -293,7 +295,7 @@ export interface InstanceInfo {
|
|||||||
export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
|
export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
|
||||||
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
|
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
|
||||||
return body && typeof body === "object" ? body : undefined
|
return body && typeof body === "object" ? body : undefined
|
||||||
@@ -341,7 +343,7 @@ export interface Usage {
|
|||||||
export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
|
export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
|
||||||
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
|
||||||
const body = (await res.json().catch(() => undefined)) as Usage | undefined
|
const body = (await res.json().catch(() => undefined)) as Usage | undefined
|
||||||
return body && typeof body === "object" ? body : undefined
|
return body && typeof body === "object" ? body : undefined
|
||||||
@@ -369,7 +371,7 @@ function asPersonalization(b: unknown): Personalization | undefined {
|
|||||||
export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
|
export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
|
||||||
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
|
||||||
if (res.status === 404) return undefined
|
if (res.status === 404) return undefined
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
|
||||||
return asPersonalization(await res.json().catch(() => undefined))
|
return asPersonalization(await res.json().catch(() => undefined))
|
||||||
}
|
}
|
||||||
@@ -393,7 +395,7 @@ export async function savePersonalization(base: string, token: string, tls: Tls
|
|||||||
if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`)
|
if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`)
|
||||||
throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization")
|
throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization")
|
||||||
}
|
}
|
||||||
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
|
if (res.status === 401) throw new SignInRejected("the instance no longer accepts this sign-in — run login again")
|
||||||
if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
|
||||||
return asPersonalization(await res.json().catch(() => undefined))
|
return asPersonalization(await res.json().catch(() => undefined))
|
||||||
}
|
}
|
||||||
+2
-2
@@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
|
|||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
import { parseDocument } from "yaml"
|
import { parseDocument } from "yaml"
|
||||||
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
||||||
import { awaitToken, checkToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
import { awaitToken, checkToken, discover, LembasError, SignInRejected, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
|
||||||
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
@@ -557,4 +557,4 @@ export async function logout(connection: string): Promise<LogoutResult> {
|
|||||||
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
|
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
export { LembasError, readCache, specFor }
|
export { LembasError, readCache, SignInRejected, specFor }
|
||||||
@@ -13,6 +13,7 @@ export interface AgentSpec {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
instructions: string
|
instructions: string
|
||||||
|
/** Read, and not used for a subagent (spec 3.3.0, subagents.md §2): shown so a file that sets it says so. */
|
||||||
model?: string
|
model?: string
|
||||||
/** A fixed permission mode; absent = the parent's. */
|
/** A fixed permission mode; absent = the parent's. */
|
||||||
mode?: Mode
|
mode?: Mode
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type
|
|||||||
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
|
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
|
||||||
import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
|
import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" }
|
||||||
import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" }
|
import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" }
|
||||||
|
import blockTeamSubmanager from "../../harness/prompts/blocks/team_submanager.md" with { type: "text" }
|
||||||
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
|
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
|
||||||
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
|
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
|
||||||
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
|
||||||
@@ -61,6 +62,7 @@ const BUILTIN: Record<string, string> = {
|
|||||||
"blocks/context.md": blockContext,
|
"blocks/context.md": blockContext,
|
||||||
"blocks/team_lead.md": blockTeamLead,
|
"blocks/team_lead.md": blockTeamLead,
|
||||||
"blocks/team_member.md": blockTeamMember,
|
"blocks/team_member.md": blockTeamMember,
|
||||||
|
"blocks/team_submanager.md": blockTeamSubmanager,
|
||||||
"agents/explore.md": agentExplore,
|
"agents/explore.md": agentExplore,
|
||||||
"agents/general.md": agentGeneral,
|
"agents/general.md": agentGeneral,
|
||||||
"modes/manual.md": modeManual,
|
"modes/manual.md": modeManual,
|
||||||
@@ -236,9 +238,9 @@ export interface PromptContext {
|
|||||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
||||||
* `report`: it has the report tool. */
|
* `report`: it has the report tool. */
|
||||||
subagent?: { name: string; instructions: string; report?: boolean }
|
subagent?: { name: string; instructions: string; report?: boolean }
|
||||||
/** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md,
|
/** Spec 3.2.0 (team.md §4), 3.4.0: a subagent of the tree — blocks/team_member.md in place of
|
||||||
* with the whole of blocks/team_lead.md for one that may lead. */
|
* blocks/subagent.md, with blocks/team_submanager.md for a sub-manager. */
|
||||||
member?: { name: string; manager: string; role: string; goal: string; lead: boolean }
|
member?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }
|
||||||
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
|
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
|
||||||
* blocks/team_lead.md is in its prompt. */
|
* blocks/team_lead.md is in its prompt. */
|
||||||
teamLead?: boolean
|
teamLead?: boolean
|
||||||
@@ -345,7 +347,7 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
if (ctx.roster && !ctx.subagent)
|
if (ctx.roster && !ctx.subagent)
|
||||||
sections.push(`Other models configured here — the task tool can run a subagent on one (its model argument); the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
|
sections.push(`Other models configured here — a subagent runs on one (task's or team_add's model argument) only when the user names it; the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
|
||||||
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
|
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
|
||||||
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
|
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
|
||||||
|
|
||||||
@@ -391,12 +393,14 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
sections.push(
|
sections.push(
|
||||||
fillPrompt(promptText("blocks/team_member.md"), {
|
fillPrompt(promptText("blocks/team_member.md"), {
|
||||||
name: ctx.member.name,
|
name: ctx.member.name,
|
||||||
|
id: ctx.member.id,
|
||||||
manager: ctx.member.manager,
|
manager: ctx.member.manager,
|
||||||
role: ctx.member.role,
|
role: ctx.member.role,
|
||||||
goal: ctx.member.goal,
|
goal: ctx.member.goal,
|
||||||
instructions: ctx.subagent.instructions,
|
instructions: ctx.subagent.instructions,
|
||||||
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
|
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
|
||||||
lead: ctx.member.lead ? promptText("blocks/team_lead.md") : "",
|
// Spec 3.4.0: a sub-manager directs; it no longer gets the whole lead block.
|
||||||
|
lead: ctx.member.lead ? promptText("blocks/team_submanager.md") : "",
|
||||||
})
|
})
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trim(),
|
.trim(),
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, hand
|
|||||||
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
|
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
|
||||||
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
||||||
retried5xx++
|
retried5xx++
|
||||||
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` }
|
const reason = e.message.split("\n")[0]!.slice(0, 160)
|
||||||
|
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${reason}`, retry: { reason, in: retried5xx === 2 ? 2 : 0, attempt: retried5xx } }
|
||||||
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
|
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export type StreamEvent =
|
|||||||
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
||||||
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
||||||
/** Something worth telling the user that is not an error, e.g. a retry. */
|
/** Something worth telling the user that is not an error, e.g. a retry. */
|
||||||
| { type: "notice"; message: string }
|
| { type: "notice"; message: string; retry?: { reason: string; in: number; attempt?: number } }
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
system: string
|
system: string
|
||||||
|
|||||||
+15
-15
@@ -20,21 +20,21 @@ export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
|
|||||||
return m.spec.single_session ? 1 : Infinity
|
return m.spec.single_session ? 1 : Infinity
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `busy`: this session's requests the helper's model already serves besides the main prompt's
|
/** `busy`: this session's requests the sub's model already serves besides the main prompt's
|
||||||
* (subagents running on it). */
|
* (subagents running on it). */
|
||||||
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string {
|
export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string {
|
||||||
if (helper.ref === main.ref) {
|
if (sub.ref === main.ref) {
|
||||||
const n = parallelOf(helper)
|
const n = parallelOf(sub)
|
||||||
if (n === 1)
|
if (n === 1)
|
||||||
return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
|
return `${sub.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
|
||||||
if (1 + busy >= n)
|
if (1 + busy >= n)
|
||||||
return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
|
return `${sub.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(main)
|
const shared = groupOf(main)
|
||||||
if (shared !== undefined && shared === groupOf(helper)) {
|
if (shared !== undefined && shared === groupOf(sub)) {
|
||||||
const where = `${main.ref}'s server (group ${main.spec.group})`
|
const where = `${main.ref}'s server (group ${main.spec.group})`
|
||||||
return `${where} holds one model at a time, so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
|
return `${where} holds one model at a time, so a subagent on ${sub.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -42,17 +42,17 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy
|
|||||||
/** The same rule between two subagents: a new one is not started where its model would unload, or
|
/** The same rule between two subagents: a new one is not started where its model would unload, or
|
||||||
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
||||||
* already running"). */
|
* already running"). */
|
||||||
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string {
|
export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
|
||||||
if (helper.ref === running.ref) {
|
if (sub.ref === running.ref) {
|
||||||
const n = parallelOf(helper)
|
const n = parallelOf(sub)
|
||||||
if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.`
|
if (n === 1) return `${sub.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.`
|
||||||
if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
|
if (busy >= n) return `${sub.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(running)
|
const shared = groupOf(running)
|
||||||
if (shared !== undefined && shared === groupOf(helper)) {
|
if (shared !== undefined && shared === groupOf(sub)) {
|
||||||
const where = `${running.ref}'s server (group ${running.spec.group})`
|
const where = `${running.ref}'s server (group ${running.spec.group})`
|
||||||
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${helper.ref} would unload it.`
|
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${sub.ref} would unload it.`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-7
@@ -17,6 +17,9 @@ import { fill, promptText } from "../prompt/assemble.ts"
|
|||||||
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||||
import { TEAM, Team } from "../team/team.ts"
|
import { TEAM, Team } from "../team/team.ts"
|
||||||
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
|
import { progressLine } from "./progress.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
|
||||||
export type CommandKind = "custom" | "skill" | "builtin"
|
export type CommandKind = "custom" | "skill" | "builtin"
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
|
|||||||
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
||||||
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
||||||
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
||||||
{ name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size <min> <max> | stop <name>" },
|
{ name: "team", description: "subagents: the tree and the room; how many; stop one; their findings", hint: "size <min> <max> | stop <name> | findings <name>" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The status bar's word for a team with members: its size of the most it may have, and how many
|
/** The status bar's word for a team with members: its size of the most it may have, and how many
|
||||||
@@ -56,7 +59,35 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
|
|||||||
export function teamLabel(team: Team): string {
|
export function teamLabel(team: Team): string {
|
||||||
if (!team.hasMembers()) return ""
|
if (!team.hasMembers()) return ""
|
||||||
const n = team.active()
|
const n = team.active()
|
||||||
return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}`
|
return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (subagents.md §12): the tree as the person sees it — every agent with its position,
|
||||||
|
* state, model, mode, context use, progress line and messages; open slots; the ended ones. */
|
||||||
|
export function treeText(team: Team, now = Date.now()): string {
|
||||||
|
const info = team.info()
|
||||||
|
const R = TEAM.read
|
||||||
|
const depth = (a: { parent: string | null }): number => (a.parent ? 1 + depth(info.agents.find((x) => x.name === a.parent) ?? { parent: null }) : 0)
|
||||||
|
const lines = [`${SUBAGENTS.setting.label} (${info.size} of ${info.max} agents; ${SUBAGENTS.setting.min} ${info.min}, ${SUBAGENTS.setting.max} ${info.max}):`]
|
||||||
|
for (const a of info.agents) {
|
||||||
|
const bits = [
|
||||||
|
`${" ".repeat(depth(a))}- ${a.name}${a.id === a.name ? "" : ` (${a.id})`} — ${a.position}`,
|
||||||
|
R.states[a.state],
|
||||||
|
a.model,
|
||||||
|
a.request.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode,
|
||||||
|
a.context && a.context.window ? `${Math.floor((a.context.used * 100) / a.context.window)}% context` : "",
|
||||||
|
a.progress ? progressLine(a.progress, now) : "",
|
||||||
|
a.messages ? `${a.messages} message${a.messages === 1 ? "" : "s"}` : "",
|
||||||
|
a.findings ? `${a.findings} finding${a.findings === 1 ? "" : "s"}` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
lines.push(bits.join(" · "))
|
||||||
|
}
|
||||||
|
if (info.open_slots) lines.push(fillPrompt(R.slots, { n: String(info.open_slots) }))
|
||||||
|
if (info.ended.length) {
|
||||||
|
lines.push(R.ended)
|
||||||
|
for (const a of info.ended) lines.push(`- ${a.name} (${a.id}) — ${a.position} · ${R.states[a.state]}${a.findings ? ` · ${a.findings} finding${a.findings === 1 ? "" : "s"}` : ""}`)
|
||||||
|
}
|
||||||
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
|
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
|
||||||
@@ -65,10 +96,11 @@ export function teamLabel(team: Team): string {
|
|||||||
export function teamText(app: App, arg: string): string {
|
export function teamText(app: App, arg: string): string {
|
||||||
const rt = app.team
|
const rt = app.team
|
||||||
const t = rt.team
|
const t = rt.team
|
||||||
|
const S = SUBAGENTS.setting
|
||||||
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
if (verb === "size") {
|
if (verb === "size") {
|
||||||
const [min, max] = rest.map(Number)
|
const [min, max] = rest.map(Number)
|
||||||
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})`
|
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} (Max at most ${t.cap}). ${S.hint}`
|
||||||
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
|
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
|
||||||
const no = Team.check(min, max, t.cap)
|
const no = Team.check(min, max, t.cap)
|
||||||
if (no) return no
|
if (no) return no
|
||||||
@@ -79,7 +111,12 @@ export function teamText(app: App, arg: string): string {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (e as Error).message
|
return (e as Error).message
|
||||||
}
|
}
|
||||||
return `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.`
|
return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.`
|
||||||
|
}
|
||||||
|
if (verb === "findings") {
|
||||||
|
const name = rest[0]
|
||||||
|
if (!name) return "/team findings <name or id>"
|
||||||
|
return t.readFindings("lead", name)
|
||||||
}
|
}
|
||||||
if (verb === "stop") {
|
if (verb === "stop") {
|
||||||
const name = rest[0]
|
const name = rest[0]
|
||||||
@@ -88,9 +125,15 @@ export function teamText(app: App, arg: string): string {
|
|||||||
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
|
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
|
||||||
}
|
}
|
||||||
if (!t.offered)
|
if (!t.offered)
|
||||||
return t.subagents ? `No team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)."
|
return t.subagents
|
||||||
|
? `No subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.`
|
||||||
|
: "No subagents in this session: they are switched off (subagents: false)."
|
||||||
const since = Math.max(0, t.room.length - TEAM.read_max)
|
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||||
return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size <min> <max>); /team stop <name> stops a member.`
|
const own = t.couldServe().own ? "" : `\n${S.own}`
|
||||||
|
// The tree as the person sees it (spec 3.4.0 §12), then the room as team_read shows it.
|
||||||
|
const read = t.read("lead", since)
|
||||||
|
const room = read.slice(read.indexOf("\n\n") + 2)
|
||||||
|
return `${treeText(t)}\n\n${room}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a subagent; /team open <name> shows its session, read-only; /team findings <name>; /context <name>.${own}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
||||||
@@ -100,7 +143,7 @@ export function agentsText(app: App, arg: string): string {
|
|||||||
const describe = (a: (typeof list)[number]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${a.name} (${a.source}) — ${a.description}`,
|
||||||
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
const name = arg.trim().split(/\s+/)[0]
|
const name = arg.trim().split(/\s+/)[0]
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|||||||
+39
-13
@@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
|
|||||||
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
|
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
|
||||||
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
|
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
|
||||||
const reported = engine.usage ? engine.contextUsed() : undefined
|
const reported = engine.usage ? engine.contextUsed() : undefined
|
||||||
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest }
|
// Where automatic compaction starts (EngineOptions compaction.autoAt): the reference's "N until
|
||||||
|
// auto-compact". None without a window, or when it is switched off (1 or more).
|
||||||
|
const autoAt = engine.o.compaction?.autoAt ?? 0.85
|
||||||
|
return { rows, estimated, reported, window: contextWindow(engine.model), autoAt: autoAt < 1 ? autoAt : undefined, model: engine.model.ref, last: engine.lastRequest }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain text, for the transcript. */
|
/** One glyph per category in the segmented bar, in the order the categories are listed. */
|
||||||
|
const SEGMENTS = ["█", "▓", "▒", "░", "▞", "▚", "▙", "▟"]
|
||||||
|
|
||||||
|
/** Plain text, for the transcript: what is used of the window and how much, one segmented bar of
|
||||||
|
* every category, each category with its tokens and its share of the window, and how far it is to
|
||||||
|
* automatic compaction (/compact compacts now). */
|
||||||
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
|
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
|
||||||
const width = 24
|
const width = 40
|
||||||
const total = b.window ?? Math.max(b.estimated, b.reported ?? 0)
|
|
||||||
const bar = (n: number) => {
|
|
||||||
const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0
|
|
||||||
return "█".repeat(Math.min(width, cells)).padEnd(width, "·")
|
|
||||||
}
|
|
||||||
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
|
||||||
const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`]
|
|
||||||
for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`)
|
|
||||||
const used = b.reported ?? b.estimated
|
const used = b.reported ?? b.estimated
|
||||||
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`)
|
const total = b.window ?? Math.max(b.estimated, used)
|
||||||
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
|
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
||||||
|
const pct = (n: number) => (b.window ? `${Math.round((n / b.window) * 100)}%` : "")
|
||||||
|
const cats = b.rows.filter((r) => !r.indent)
|
||||||
|
// The categories' shares of the bar, scaled to what is in use (the server's count when it gave one).
|
||||||
|
const scale = b.estimated ? used / b.estimated : 1
|
||||||
|
let bar = ""
|
||||||
|
cats.forEach((r, i) => {
|
||||||
|
const cells = total && r.tokens > 0 ? Math.max(1, Math.round(((r.tokens * scale) / total) * width)) : 0
|
||||||
|
bar += SEGMENTS[i % SEGMENTS.length]!.repeat(cells)
|
||||||
|
})
|
||||||
|
bar = bar.slice(0, width).padEnd(width, "·")
|
||||||
|
const lines = [
|
||||||
|
`context — ${b.model}: ${fmt(used)}${b.window ? ` / ${fmt(b.window)} tokens · ${pct(used)} used` : " tokens, window unknown"}`,
|
||||||
|
` ${bar}`,
|
||||||
|
]
|
||||||
|
let i = 0
|
||||||
|
for (const r of b.rows) {
|
||||||
|
const mark = r.indent ? " ↳" : ` ${SEGMENTS[i++ % SEGMENTS.length]} `
|
||||||
|
lines.push(`${mark} ${r.label.padEnd(r.indent ? 30 : 31)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : pct(r.tokens * scale).padStart(4)}`.trimEnd())
|
||||||
|
}
|
||||||
|
if (b.window) lines.push(` · ${"free".padEnd(31)} ${fmt(Math.max(0, b.window - used)).padStart(7)} ${pct(Math.max(0, b.window - used)).padStart(4)}`)
|
||||||
|
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${pct(used).padStart(4)}`.trimEnd())
|
||||||
|
if (b.window && b.autoAt !== undefined) {
|
||||||
|
const until = Math.round(b.window * b.autoAt) - used
|
||||||
|
lines.push(until > 0 ? ` ${fmt(until)} until auto-compact (at ${Math.round(b.autoAt * 100)}%) — /compact to compact now` : ` past auto-compact (${Math.round(b.autoAt * 100)}%): the next step compacts — /compact to compact now`)
|
||||||
|
} else lines.push(b.window ? " automatic compaction is off — /compact to compact now" : " no automatic compaction without a window — /compact to compact now")
|
||||||
|
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since; the categories are estimates scaled to it)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
|
||||||
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
|
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
|
||||||
// little of it cached was a re-read.
|
// little of it cached was a re-read.
|
||||||
const last = b.last?.usage
|
const last = b.last?.usage
|
||||||
|
|||||||
+68
-10
@@ -8,7 +8,7 @@ import { isAbsolute } from "node:path"
|
|||||||
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
|
||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import type { Level } from "../provider/effort.ts"
|
import type { Level } from "../provider/effort.ts"
|
||||||
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
|
import { evaluate, shadowsDeny, type Context as PermContext, type PermissionRequest, type Rule } from "../permission/evaluate.ts"
|
||||||
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
|
||||||
import type { Plan } from "../plan/plan.ts"
|
import type { Plan } from "../plan/plan.ts"
|
||||||
import { LOOP } from "../harness.ts"
|
import { LOOP } from "../harness.ts"
|
||||||
@@ -22,6 +22,9 @@ import { estimateTokens, estimateValue } from "../provider/tokens.ts"
|
|||||||
import { duration } from "../duration.ts"
|
import { duration } from "../duration.ts"
|
||||||
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
||||||
import { render } from "../plan/plan.ts"
|
import { render } from "../plan/plan.ts"
|
||||||
|
import { personTexts } from "../team/subagents.ts"
|
||||||
|
import { ProgressTracker } from "./progress.ts"
|
||||||
|
import type { Verdict } from "../team/delegation.ts"
|
||||||
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
|
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
@@ -77,6 +80,15 @@ export interface EngineOptions {
|
|||||||
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
|
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
|
||||||
forget?(): void
|
forget?(): void
|
||||||
beforeTurn?(signal: AbortSignal): Promise<boolean>
|
beforeTurn?(signal: AbortSignal): Promise<boolean>
|
||||||
|
/** Spec 3.4.0 (subagents.md §7), a subagent: who decides its call — itself, the main agent
|
||||||
|
* (through a request), or the person. Replaces the plain evaluation. */
|
||||||
|
permit?(request: PermissionRequest, ctx: Omit<PermContext, "mode">): Verdict
|
||||||
|
/** Spec 3.4.0 §9, a subagent: its context reached `pause_at` of its window before a step.
|
||||||
|
* "stop": its turn ends now (it is paused; its final turn follows); "go": carry on without
|
||||||
|
* compacting (it is in its final turn). Undefined: not a subagent of the tree. */
|
||||||
|
contextFull?(used: number, window: number): "stop" | "go" | undefined
|
||||||
|
/** Spec 3.4.0, a subagent: it ended by its own call (team_finish): the turn stops after this step. */
|
||||||
|
ended?(): boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +98,8 @@ export interface PromptMeta {
|
|||||||
attachments?: AttachmentInfo[]
|
attachments?: AttachmentInfo[]
|
||||||
/** The harness started this turn — a job's note — not the person. */
|
/** The harness started this turn — a job's note — not the person. */
|
||||||
machine?: true
|
machine?: true
|
||||||
|
/** Spec 3.4.0: the steps this turn may take, when fewer than the engine's (a subagent's final turn). */
|
||||||
|
maxSteps?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PARALLEL = LOOP.parallel.max
|
const MAX_PARALLEL = LOOP.parallel.max
|
||||||
@@ -186,7 +200,11 @@ export class Engine {
|
|||||||
/** The prompt the team's offer was last decided for. */
|
/** The prompt the team's offer was last decided for. */
|
||||||
private teamFixed: Kept | undefined
|
private teamFixed: Kept | undefined
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what this agent is doing, said as `phase` events. */
|
||||||
|
readonly progress: ProgressTracker
|
||||||
|
|
||||||
constructor(readonly o: EngineOptions) {
|
constructor(readonly o: EngineOptions) {
|
||||||
|
this.progress = new ProgressTracker(o.bus)
|
||||||
this.mode = o.mode
|
this.mode = o.mode
|
||||||
this._effort = o.effort
|
this._effort = o.effort
|
||||||
this.model = o.model
|
this.model = o.model
|
||||||
@@ -463,8 +481,19 @@ export class Engine {
|
|||||||
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
|
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
|
||||||
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
|
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
|
||||||
* prompt cache away from that point); compaction is the one deliberate break. */
|
* prompt cache away from that point); compaction is the one deliberate break. */
|
||||||
private async manageContext(signal: AbortSignal) {
|
private async manageContext(signal: AbortSignal): Promise<"stop" | void> {
|
||||||
const window = contextWindow(this.model)
|
const window = contextWindow(this.model)
|
||||||
|
// Spec 3.4.0 §9: a subagent of the tree does not compact; at pause_at it is paused instead.
|
||||||
|
const full = this.o.team?.contextFull
|
||||||
|
if (full && window) {
|
||||||
|
const used = this.contextUsed()
|
||||||
|
if (used !== undefined && used >= window * LOOP.team.context.pause_at) {
|
||||||
|
const r = full(used, window)
|
||||||
|
if (r === "stop") return "stop"
|
||||||
|
if (r === "go") return
|
||||||
|
}
|
||||||
|
if (full(0, 0) !== undefined) return
|
||||||
|
}
|
||||||
const autoAt = this.o.compaction?.autoAt ?? 0.85
|
const autoAt = this.o.compaction?.autoAt ?? 0.85
|
||||||
if (!window || autoAt >= 1) return
|
if (!window || autoAt >= 1) return
|
||||||
const limit = window * autoAt
|
const limit = window * autoAt
|
||||||
@@ -481,6 +510,7 @@ export class Engine {
|
|||||||
}
|
}
|
||||||
this.autoCompactions++
|
this.autoCompactions++
|
||||||
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
|
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
|
||||||
|
this.progress.set({ phase: "compacting" })
|
||||||
try {
|
try {
|
||||||
await this.summarise(signal)
|
await this.summarise(signal)
|
||||||
// The task is never left to the summary: the prompt that started this turn comes back word
|
// The task is never left to the summary: the prompt that started this turn comes back word
|
||||||
@@ -507,11 +537,13 @@ export class Engine {
|
|||||||
|
|
||||||
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who
|
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who
|
||||||
* thinks for ten minutes about one command has not spent the agent's time. */
|
* thinks for ten minutes about one command has not spent the agent's time. */
|
||||||
private async waiting<T>(p: Promise<T>): Promise<T> {
|
private async waiting<T>(p: Promise<T>, what: "approval" | "question" | "plan" = "approval", tool?: string): Promise<T> {
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
const back = this.progress.hold({ phase: "waiting_for_person", what, ...(tool ? { tool } : {}) })
|
||||||
try {
|
try {
|
||||||
return await p
|
return await p
|
||||||
} finally {
|
} finally {
|
||||||
|
back()
|
||||||
this.turnWaitedMs += performance.now() - t0
|
this.turnWaitedMs += performance.now() - t0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,6 +643,17 @@ export class Engine {
|
|||||||
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
|
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the person typed in this session, oldest first (subagents.md §2) — compacted messages
|
||||||
|
* included, as the store keeps them; never a note, a marked message or a machine turn. */
|
||||||
|
personSaid(): string[] {
|
||||||
|
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
|
||||||
|
return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this
|
||||||
|
* device has not seen, as the client sent them (`said` on session/new). */
|
||||||
|
saidBefore: string[] = []
|
||||||
|
|
||||||
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
||||||
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
||||||
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
@@ -618,13 +661,13 @@ export class Engine {
|
|||||||
this.busy = true
|
this.busy = true
|
||||||
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) })
|
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) })
|
||||||
try {
|
try {
|
||||||
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine)
|
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine, meta.maxSteps)
|
||||||
} finally {
|
} finally {
|
||||||
this.busy = false
|
this.busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true, stepCap?: number): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
const { bus } = this.o
|
const { bus } = this.o
|
||||||
this.abort = new AbortController()
|
this.abort = new AbortController()
|
||||||
const signal = this.abort.signal
|
const signal = this.abort.signal
|
||||||
@@ -648,7 +691,7 @@ export class Engine {
|
|||||||
this.turnSteers = []
|
this.turnSteers = []
|
||||||
this.autoCompactions = 0
|
this.autoCompactions = 0
|
||||||
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
|
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
|
||||||
const maxSteps = this.o.maxSteps ?? LOOP.steps.default
|
const maxSteps = Math.min(this.o.maxSteps ?? LOOP.steps.default, stepCap ?? Infinity)
|
||||||
const started = performance.now()
|
const started = performance.now()
|
||||||
this.turnWaitedMs = 0
|
this.turnWaitedMs = 0
|
||||||
this.turnOutputBytes = 0
|
this.turnOutputBytes = 0
|
||||||
@@ -666,7 +709,10 @@ export class Engine {
|
|||||||
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
|
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` })
|
||||||
}
|
}
|
||||||
const wrapUp = step > maxSteps || ranOut !== undefined
|
const wrapUp = step > maxSteps || ranOut !== undefined
|
||||||
if (!wrapUp) await this.manageContext(signal)
|
if (!wrapUp && (await this.manageContext(signal)) === "stop") {
|
||||||
|
bus.emit({ type: "done", reason: "stop" })
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
// After compacting, so what was just sent is never folded into a summary.
|
// After compacting, so what was just sent is never folded into a summary.
|
||||||
if (!wrapUp) this.deliverInbox()
|
if (!wrapUp) this.deliverInbox()
|
||||||
bus.emit({ type: "step", n: step })
|
bus.emit({ type: "step", n: step })
|
||||||
@@ -809,11 +855,11 @@ export class Engine {
|
|||||||
...this.o.toolCtx,
|
...this.o.toolCtx,
|
||||||
signal,
|
signal,
|
||||||
backgroundNow: bgNow.signal,
|
backgroundNow: bgNow.signal,
|
||||||
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
|
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id), "question") : undefined,
|
||||||
plan: {
|
plan: {
|
||||||
get: () => this.plan,
|
get: () => this.plan,
|
||||||
set: (p) => this.setPlan(p, "model"),
|
set: (p) => this.setPlan(p, "model"),
|
||||||
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
|
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id), "plan") : undefined,
|
||||||
},
|
},
|
||||||
setMode: (m) => {
|
setMode: (m) => {
|
||||||
this.mode = m
|
this.mode = m
|
||||||
@@ -873,6 +919,11 @@ export class Engine {
|
|||||||
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
// No dialect takes an image inside a tool result the same way; a user turn right after the
|
||||||
// results works everywhere.
|
// results works everywhere.
|
||||||
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
|
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false })
|
||||||
|
// Spec 3.4.0: a subagent that called team_finish has ended — nothing runs after that call.
|
||||||
|
if (this.o.team?.ended?.()) {
|
||||||
|
bus.emit({ type: "done", reason: "stop" })
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
bus.emit({ type: "done", reason: "cancelled" })
|
bus.emit({ type: "done", reason: "cancelled" })
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
@@ -943,6 +994,13 @@ export class Engine {
|
|||||||
let edited: string | undefined
|
let edited: string | undefined
|
||||||
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
|
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
|
||||||
let decision = evaluate(request, ctx)
|
let decision = evaluate(request, ctx)
|
||||||
|
// Spec 3.4.0 (subagents.md §7): a subagent of the tree — itself, the main agent, or the person.
|
||||||
|
if (team?.permit) {
|
||||||
|
const { mode: _m, ...rest } = ctx
|
||||||
|
const v = team.permit(request, rest)
|
||||||
|
if (v.answer === "refused") return { result: { output: v.text, isError: true }, denied: true }
|
||||||
|
decision = { ...v.decision, action: v.answer === "run" ? "allow" : "ask" }
|
||||||
|
}
|
||||||
|
|
||||||
// Doom-loop guard: the same call three times running is asked about, whatever the mode.
|
// Doom-loop guard: the same call three times running is asked about, whatever the mode.
|
||||||
const key = `${call.name} ${JSON.stringify(args)}`
|
const key = `${call.name} ${JSON.stringify(args)}`
|
||||||
@@ -959,7 +1017,7 @@ export class Engine {
|
|||||||
(diff) => ({ diff }),
|
(diff) => ({ diff }),
|
||||||
(e) => ({ error: (e as Error).message }),
|
(e) => ({ error: (e as Error).message }),
|
||||||
)
|
)
|
||||||
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }))
|
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }), "approval", tool.name)
|
||||||
if (reply.kind === "deny" && looping)
|
if (reply.kind === "deny" && looping)
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -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
|
// A lead session's team at work (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13): the
|
||||||
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
|
// rules of team.ts driven by real turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
|
||||||
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
|
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
|
||||||
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
|
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
|
||||||
// a working agent at its next step, an agent is stopped.
|
// a working agent at its next step, an agent is stopped.
|
||||||
@@ -8,10 +8,14 @@ import { TOOL_SPECS } from "../harness.ts"
|
|||||||
import type { Engine } from "../session/engine.ts"
|
import type { Engine } from "../session/engine.ts"
|
||||||
import type { Tool } from "../tool/tool.ts"
|
import type { Tool } from "../tool/tool.ts"
|
||||||
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
|
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
|
||||||
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import type { ToolClass } from "../permission/evaluate.ts"
|
||||||
|
|
||||||
/** A member's engine, as the app made it. */
|
/** A member's engine, as the app made it. */
|
||||||
export interface MemberHandle {
|
export interface MemberHandle {
|
||||||
engine: Engine
|
engine: Engine
|
||||||
|
/** Its one final turn after its context filled (spec 3.4.0 §9), with this text, few steps. */
|
||||||
|
final?(text: string): void
|
||||||
/** Its next turn, with this text as the prompt (its context intact). */
|
/** Its next turn, with this text as the prompt (its context intact). */
|
||||||
run(text: string): void
|
run(text: string): void
|
||||||
/** Stop it where it is; nothing more comes from it. */
|
/** Stop it where it is; nothing more comes from it. */
|
||||||
@@ -25,10 +29,12 @@ export interface RuntimeOptions {
|
|||||||
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
|
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
|
||||||
spawnMember(member: TeamAgent, text: string): void
|
spawnMember(member: TeamAgent, text: string): void
|
||||||
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
|
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
|
||||||
lead: { note(text: string): void; wake(text: string): void }
|
lead: { note(text: string): void; wake(text: string): void; /** Spec 3.4.0: its progress while it waits for a slot. */ waiting?(model: string): void }
|
||||||
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
|
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
|
||||||
* model's ref as the session knows it. */
|
* model's ref as the session knows it. */
|
||||||
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
|
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
|
||||||
|
/** A tool's risk class and permission key, by its name (team_request, team_decide). */
|
||||||
|
tools?: () => readonly Tool[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamRuntime {
|
export class TeamRuntime {
|
||||||
@@ -36,6 +42,11 @@ export class TeamRuntime {
|
|||||||
private members = new Map<string, MemberHandle>()
|
private members = new Map<string, MemberHandle>()
|
||||||
/** The lead's turn waiting for a slot: resolved when it is given one. */
|
/** The lead's turn waiting for a slot: resolved when it is given one. */
|
||||||
private leadWaiter: (() => void) | undefined
|
private leadWaiter: (() => void) | undefined
|
||||||
|
/** Spec 3.4.0: subagents that ended by their own team_finish (their turn stops after the call),
|
||||||
|
* and the final turn waiting for one whose context filled. */
|
||||||
|
private ended = new Set<string>()
|
||||||
|
private finals = new Map<string, string>()
|
||||||
|
private pace: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
constructor(private o: RuntimeOptions) {
|
constructor(private o: RuntimeOptions) {
|
||||||
this.team = o.team
|
this.team = o.team
|
||||||
@@ -47,6 +58,8 @@ export class TeamRuntime {
|
|||||||
start: (name, text) => this.start(name, text),
|
start: (name, text) => this.start(name, text),
|
||||||
deliver: (name, text) => this.deliver(name, text),
|
deliver: (name, text) => this.deliver(name, text),
|
||||||
stop: (name) => this.stopped(name),
|
stop: (name) => this.stopped(name),
|
||||||
|
end: (name) => void this.ended.add(name),
|
||||||
|
final: (name, text) => void this.finals.set(name, text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +72,82 @@ export class TeamRuntime {
|
|||||||
this.changed()
|
this.changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Said on the lead's bus as `team`, at most four times a second (acp.md `team` event). */
|
||||||
private changed() {
|
private changed() {
|
||||||
|
// A change inside the pace window is said once at its end, so the last state always goes out.
|
||||||
|
if (this.pace) return void (this.trailing = true)
|
||||||
this.o.bus.emit({ type: "team" })
|
this.o.bus.emit({ type: "team" })
|
||||||
|
this.pace = setTimeout(() => {
|
||||||
|
this.pace = undefined
|
||||||
|
if (this.trailing) {
|
||||||
|
this.trailing = false
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
}, 1000 / 4)
|
||||||
|
}
|
||||||
|
private trailing = false
|
||||||
|
|
||||||
|
/** Something changed that a reader must see now (the tools' answers, tests): said at once. */
|
||||||
|
private changedNow() {
|
||||||
|
if (this.pace) clearTimeout(this.pace)
|
||||||
|
this.pace = undefined
|
||||||
|
this.trailing = false
|
||||||
|
this.o.bus.emit({ type: "team" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool's risk class, for team_request. */
|
||||||
|
classOf(tool: string): ToolClass | undefined {
|
||||||
|
const spec = TOOL_SPECS[tool]?.risk
|
||||||
|
if (spec) return spec as ToolClass
|
||||||
|
const t = this.o.tools?.().find((x) => x.name === tool)
|
||||||
|
try {
|
||||||
|
return t ? (t.permission({} as never, {} as never).class as ToolClass) : undefined
|
||||||
|
} catch {
|
||||||
|
return "execute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool's permission key (edit and write share `edit`), for a grant. */
|
||||||
|
permissionKeyOf(tool: string): string | undefined {
|
||||||
|
return this.o.tools?.().find((x) => x.name === tool)?.access ?? tool
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every subagent's engine in its team's mode (spec 3.4.0 §7: the main agent made stricter, an
|
||||||
|
* assignment). */
|
||||||
|
syncModes() {
|
||||||
|
for (const [name, h] of this.members) {
|
||||||
|
const m = this.team.get(name)?.mode
|
||||||
|
if (m && h.engine.mode !== m) h.engine.mode = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main agent's mode changed (the person, plan approved): stricter reaches down the tree. */
|
||||||
|
mainMode(mode: Mode) {
|
||||||
|
this.team.setMainMode(mode)
|
||||||
|
this.syncModes()
|
||||||
|
this.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0: whether this subagent ended by its own call (its engine stops after the step). */
|
||||||
|
hasEnded(name: string): boolean {
|
||||||
|
return this.ended.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 §9: its context reached pause_at. "stop" the first time (it is paused, its final
|
||||||
|
* turn follows), "go" in that final turn (no compaction, it carries on). */
|
||||||
|
contextFull(name: string): "stop" | "go" {
|
||||||
|
const a = this.team.get(name)
|
||||||
|
if (!a || a.state === "paused") return "go"
|
||||||
|
this.team.pause(name)
|
||||||
|
this.changedNow()
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a member's run should do when its turn ended: its final turn (context full), or report. */
|
||||||
|
takeFinal(name: string): string | undefined {
|
||||||
|
const t = this.finals.get(name)
|
||||||
|
this.finals.delete(name)
|
||||||
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Something the tools did changed the team: said, for whoever shows it. */
|
/** Something the tools did changed the team: said, for whoever shows it. */
|
||||||
@@ -70,7 +157,7 @@ export class TeamRuntime {
|
|||||||
|
|
||||||
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
|
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
|
||||||
* team's own refusals (the name, the goal, the size), which come first. */
|
* team's own refusals (the name, the goal, the size), which come first. */
|
||||||
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
|
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
|
||||||
const agent = a.agent ?? "general"
|
const agent = a.agent ?? "general"
|
||||||
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
|
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
|
||||||
// The team's refusals first; an added member needs a known agent and model.
|
// The team's refusals first; an added member needs a known agent and model.
|
||||||
@@ -111,6 +198,7 @@ export class TeamRuntime {
|
|||||||
private stopped(name: string) {
|
private stopped(name: string) {
|
||||||
const h = this.members.get(name)
|
const h = this.members.get(name)
|
||||||
this.members.delete(name)
|
this.members.delete(name)
|
||||||
|
this.finals.delete(name)
|
||||||
h?.cancel()
|
h?.cancel()
|
||||||
this.changed()
|
this.changed()
|
||||||
}
|
}
|
||||||
@@ -125,6 +213,7 @@ export class TeamRuntime {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
|
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
|
||||||
|
this.o.lead.waiting?.(this.team.model)
|
||||||
this.changed()
|
this.changed()
|
||||||
return await new Promise<boolean>((resolve) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
@@ -147,8 +236,21 @@ export class TeamRuntime {
|
|||||||
this.changed()
|
this.changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A member's turn ended: its reply is its report; messages it was given too late wake it again. */
|
/** A member's turn ended: its reply is its report; messages it was given too late wake it again.
|
||||||
|
* Spec 3.4.0: one that ended by its own team_finish is let go; one whose context filled gets its
|
||||||
|
* final turn first. */
|
||||||
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
|
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
|
||||||
|
if (this.ended.delete(name)) {
|
||||||
|
this.members.delete(name)
|
||||||
|
this.changed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const final = this.takeFinal(name)
|
||||||
|
if (final !== undefined && this.team.get(name)?.state === "paused") {
|
||||||
|
this.members.get(name)?.final?.(final)
|
||||||
|
this.changed()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!this.team.get(name)) return
|
if (!this.team.get(name)) return
|
||||||
this.team.finish(name, reply, reason)
|
this.team.finish(name, reply, reason)
|
||||||
if (leftover.length) this.team.undelivered(name, leftover)
|
if (leftover.length) this.team.undelivered(name, leftover)
|
||||||
@@ -157,7 +259,6 @@ export class TeamRuntime {
|
|||||||
|
|
||||||
/** What a call answers before its permission check, for the agent `as`. */
|
/** What a call answers before its permission check, for the agent `as`. */
|
||||||
refuse(as: string, tool: Tool, args: unknown): string | undefined {
|
refuse(as: string, tool: Tool, args: unknown): string | undefined {
|
||||||
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
|
|
||||||
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
|
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
|
||||||
// says readOnlyHint, interact for the server's memory, else execute.
|
// says readOnlyHint, interact for the server's memory, else execute.
|
||||||
let risk: string | undefined
|
let risk: string | undefined
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Subagents (harness spec 3.3.0, harness/subagents.md): which model a subagent — a task subagent or
|
||||||
|
// a team's member — runs on, and whether subagents are offered at all. The rules alone, without an
|
||||||
|
// engine: the conformance area `subagents` drives them, and the app (spawn, team_add) asks them.
|
||||||
|
import { LOOP } from "../harness.ts"
|
||||||
|
import type { Message } from "../provider/types.ts"
|
||||||
|
import { parallelFrom, type Capacity } from "./team.ts"
|
||||||
|
|
||||||
|
export const SUBAGENTS = LOOP.subagents
|
||||||
|
const TEXTS = SUBAGENTS.texts
|
||||||
|
|
||||||
|
/** A model the person may use: its served id (the ref), its id at its connection, its display name. */
|
||||||
|
export interface ModelInfo {
|
||||||
|
ref: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
capacity: Capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
const BEFORE = new RegExp(`${SUBAGENTS.named.before}$`, "u")
|
||||||
|
const AFTER = new RegExp(`^(?:${SUBAGENTS.named.after})`, "u")
|
||||||
|
|
||||||
|
/** The names a model goes by: the served id, the part after its provider, the display name. */
|
||||||
|
export function namesOf(m: Pick<ModelInfo, "ref" | "id" | "name">): string[] {
|
||||||
|
const out = [m.ref]
|
||||||
|
if (m.id) out.push(m.id)
|
||||||
|
const slash = m.ref.lastIndexOf("/")
|
||||||
|
if (slash >= 0) out.push(m.ref.slice(slash + 1))
|
||||||
|
if (m.name?.trim()) out.push(m.name.trim())
|
||||||
|
return [...new Set(out.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `name` occurs in `text` as a whole word, without case (subagents.md §2). White space in
|
||||||
|
* the name matches any run of white space. */
|
||||||
|
export function occursAsWord(text: string, name: string): boolean {
|
||||||
|
const pattern = name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(escape)
|
||||||
|
.join("\\s+")
|
||||||
|
if (!pattern) return false
|
||||||
|
const re = new RegExp(pattern, "giu")
|
||||||
|
for (let m = re.exec(text); m; m = re.exec(text)) {
|
||||||
|
const before = text.slice(0, m.index)
|
||||||
|
const after = text.slice(m.index + m[0].length)
|
||||||
|
if (!BEFORE.test(before) && !AFTER.test(after)) return true
|
||||||
|
re.lastIndex = m.index + 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the person named the model in any of the messages they typed. */
|
||||||
|
export function named(m: Pick<ModelInfo, "ref" | "id" | "name">, said: readonly string[]): boolean {
|
||||||
|
const names = namesOf(m)
|
||||||
|
return said.some((t) => names.some((n) => occursAsWord(t, n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the person typed in a session, oldest first: their prompts and what they sent mid-turn
|
||||||
|
* (after `steerMark`) — never a note, a job's or a team's marked message, or a machine turn. */
|
||||||
|
export function personTexts(messages: readonly Message[], steerMark: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role !== "user" || m.machine) continue
|
||||||
|
const first = m.parts[0]
|
||||||
|
if (!first || first.type !== "text") continue
|
||||||
|
if (m.turn === false) {
|
||||||
|
if (first.text.startsWith(steerMark)) out.push(first.text.slice(steerMark.length).trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.push(first.text)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a model can be its own session's subagent: it serves two requests at once, or has no limit. */
|
||||||
|
export function servesOwn(c: Capacity): boolean {
|
||||||
|
return parallelFrom(c) >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** subagents.md §4: whether subagents are offered for a session on `session`, given the models the
|
||||||
|
* person may use, and whether the session's model can serve its own. */
|
||||||
|
export function offer(session: ModelInfo, models: readonly ModelInfo[], subagents = true): { offered: boolean; own: boolean } {
|
||||||
|
const own = servesOwn(session.capacity)
|
||||||
|
const group = session.capacity.group
|
||||||
|
const other = models.some((m) => m.ref !== session.ref && (group === undefined || m.capacity.group !== group))
|
||||||
|
return { offered: subagents && (own || other), own }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** subagents.md §2, §3: the model a subagent runs on — the session's, or `wanted` (already resolved
|
||||||
|
* to a model the person may use) — or the refusal, word for word. */
|
||||||
|
export function subagentModel(session: ModelInfo, wanted: ModelInfo | undefined, said: readonly string[]): { model: string } | { refused: string } {
|
||||||
|
const m = wanted ?? session
|
||||||
|
if (m.ref !== session.ref) {
|
||||||
|
if (!named(m, said)) return { refused: TEXTS.not_named }
|
||||||
|
const group = session.capacity.group
|
||||||
|
if (group !== undefined && m.capacity.group === group) return { refused: TEXTS.same_group }
|
||||||
|
return { model: m.ref }
|
||||||
|
}
|
||||||
|
if (!servesOwn(session.capacity)) return { refused: TEXTS.one_request }
|
||||||
|
return { model: session.ref }
|
||||||
|
}
|
||||||
+462
-49
@@ -1,5 +1,7 @@
|
|||||||
// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives
|
// Teams (harness spec 3.2.0, harness/team.md; 3.4.0, harness/subagents.md §5–§13): the main agent
|
||||||
// what, the size the person allows, a manager's refusals, and the slots of the model they run on.
|
// (`lead`) and its subagents, their room, who receives what, the size the person allows, a
|
||||||
|
// manager's refusals, the slots of the model they run on — and from 3.4.0 the tree only the main
|
||||||
|
// agent builds, modes and request options, findings, and a subagent's end.
|
||||||
// This file is the rules alone, without an engine: the conformance area `team` drives it step by
|
// This file is the rules alone, without an engine: the conformance area `team` drives it step by
|
||||||
// step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's
|
// step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's
|
||||||
// turn starts (it was given a slot), when a message reaches an agent that is working, and when an
|
// turn starts (it was given a slot), when a message reaches an agent that is working, and when an
|
||||||
@@ -9,14 +11,42 @@ import { fillPrompt } from "../prompt/fill.ts"
|
|||||||
import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts"
|
import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts"
|
||||||
import { BUILTIN_HARDLINE } from "../permission/hardline.ts"
|
import { BUILTIN_HARDLINE } from "../permission/hardline.ts"
|
||||||
import { duration } from "../duration.ts"
|
import { duration } from "../duration.ts"
|
||||||
|
import type { Mode } from "../config/schema.ts"
|
||||||
|
import type { ToolClass } from "../permission/evaluate.ts"
|
||||||
|
import { checkMode, strictest, type Grant } from "./delegation.ts"
|
||||||
|
import type { Progress } from "../session/progress.ts"
|
||||||
|
|
||||||
export const TEAM = LOOP.team
|
export const TEAM = LOOP.team
|
||||||
const TEXTS = TEAM.texts
|
const TEXTS = TEAM.texts
|
||||||
const LEAD = TEAM.names.lead
|
const LEAD = TEAM.names.lead
|
||||||
export const TEAM_TOOLS = ["team_add", "team_read", "team_send", "team_stop"] as const
|
export const TEAM_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami"] as const
|
||||||
|
/** Spec 3.4.0 (subagents.md §8): the main agent's alone. */
|
||||||
|
export const MAIN_ONLY = TEAM.main_only.tools as readonly string[]
|
||||||
|
/** The main agent's team tools, and a subagent's (with team_request only when it has request options). */
|
||||||
|
export const LEAD_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_read", "team_replace", "team_send", "team_stop", "team_whoami"] as const
|
||||||
|
export const MEMBER_TOOLS = ["team_findings", "team_finish", "team_read", "team_send", "team_whoami"] as const
|
||||||
|
export function memberTools(a: { request: readonly string[] }): string[] {
|
||||||
|
return [...MEMBER_TOOLS, ...(a.request.length ? ["team_request"] : [])].sort()
|
||||||
|
}
|
||||||
const NAME = new RegExp(TEAM.names.pattern)
|
const NAME = new RegExp(TEAM.names.pattern)
|
||||||
|
|
||||||
export type AgentState = "working" | "waiting" | "finished" | "idle"
|
export type AgentState = "working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped"
|
||||||
|
export type EndState = "done" | "out_of_context" | "stopped"
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
n: number
|
||||||
|
text: string
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
tool: string
|
||||||
|
pattern: string
|
||||||
|
why: string
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** What a model's server can do at once (models/schema.json capacity). */
|
/** What a model's server can do at once (models/schema.json capacity). */
|
||||||
export interface Capacity {
|
export interface Capacity {
|
||||||
@@ -32,11 +62,26 @@ export function parallelFrom(c: Capacity | undefined): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamAgent {
|
export interface TeamAgent {
|
||||||
|
/** `lead` for the main agent; s1, s2, … for subagents, never reused (spec 3.4.0). */
|
||||||
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
/** Its position (team_add's role). */
|
||||||
role: string
|
role: string
|
||||||
|
/** Its task, as given and as assigned since. */
|
||||||
|
task: string
|
||||||
|
mode: Mode
|
||||||
|
/** Request options (spec 3.4.0 §7). */
|
||||||
|
request: ToolClass[]
|
||||||
|
/** What the main agent allowed it (team_decide). */
|
||||||
|
grants: Grant[]
|
||||||
|
findings: Finding[]
|
||||||
|
/** Its context use, as its engine last said it. */
|
||||||
|
context?: { used: number; window: number }
|
||||||
|
/** Its current progress (progress.md). */
|
||||||
|
progress?: Progress | null
|
||||||
/** Who added it; null for the lead. */
|
/** Who added it; null for the lead. */
|
||||||
manager: string | null
|
manager: string | null
|
||||||
/** May add members of its own (team_add's `lead`); the lead may. */
|
/** A sub-manager (team_add's `lead`), or the main agent: subagents may be placed under it. */
|
||||||
leads: boolean
|
leads: boolean
|
||||||
/** The team's goal as it was told it (the lead's: the last one it gave). */
|
/** The team's goal as it was told it (the lead's: the last one it gave). */
|
||||||
goal: string
|
goal: string
|
||||||
@@ -54,6 +99,13 @@ export interface TeamAgent {
|
|||||||
ended?: number
|
ended?: number
|
||||||
/** Added in this order (tree order among siblings). */
|
/** Added in this order (tree order among siblings). */
|
||||||
seq: number
|
seq: number
|
||||||
|
/** How it ended, and when (an ended subagent stays in the tree's `ended`). */
|
||||||
|
endState?: EndState
|
||||||
|
endedAt?: number
|
||||||
|
/** The sub-manager it was under before that one ended (team_replace puts it back). */
|
||||||
|
movedFrom?: string
|
||||||
|
/** The device's job that runs it (its transcript, read-only). */
|
||||||
|
jobId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomMessage {
|
export interface RoomMessage {
|
||||||
@@ -65,6 +117,38 @@ export interface RoomMessage {
|
|||||||
text: string
|
text: string
|
||||||
time: number
|
time: number
|
||||||
report: boolean
|
report: boolean
|
||||||
|
/** Spec 3.4.0: what it is — a message, a report, an assignment, a request, a decision. */
|
||||||
|
kind: RoomKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomKind = "message" | "report" | "assignment" | "request" | "decision"
|
||||||
|
|
||||||
|
/** A row of the tree (subagents.md §12). */
|
||||||
|
export interface TreeAgent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
position: string
|
||||||
|
parent: string | null
|
||||||
|
state: AgentState
|
||||||
|
model: string
|
||||||
|
mode: Mode
|
||||||
|
request: string[]
|
||||||
|
manager: boolean
|
||||||
|
task?: string
|
||||||
|
context?: { used: number; window: number }
|
||||||
|
progress: Progress | null
|
||||||
|
findings: number
|
||||||
|
messages: number
|
||||||
|
ended?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeInfo {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
size: number
|
||||||
|
open_slots: number
|
||||||
|
agents: TreeAgent[]
|
||||||
|
ended: TreeAgent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamEffects {
|
export interface TeamEffects {
|
||||||
@@ -75,6 +159,11 @@ export interface TeamEffects {
|
|||||||
deliver(name: string, text: string): void
|
deliver(name: string, text: string): void
|
||||||
/** It left the team: its turn stops where it is, its background commands with it. */
|
/** It left the team: its turn stops where it is, its background commands with it. */
|
||||||
stop(name: string): void
|
stop(name: string): void
|
||||||
|
/** Spec 3.4.0: it ended by its own team_finish — its turn stops after that call. */
|
||||||
|
end?(name: string): void
|
||||||
|
/** Spec 3.4.0: its context is full — its turn stops after this step, and its one final turn
|
||||||
|
* starts with `text`. */
|
||||||
|
final?(name: string, text: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamOptions {
|
export interface TeamOptions {
|
||||||
@@ -86,12 +175,19 @@ export interface TeamOptions {
|
|||||||
/** The lead's model (the chat's), and every model's capacity. */
|
/** The lead's model (the chat's), and every model's capacity. */
|
||||||
model: string
|
model: string
|
||||||
capacity: (model: string) => Capacity
|
capacity: (model: string) => Capacity
|
||||||
|
/** Spec 3.3.0 (subagents.md §4): every model the person may use, by ref — a model off the lead's
|
||||||
|
* one-model server can serve as a subagent where the lead's model cannot. None: only the lead's. */
|
||||||
|
models?: () => readonly string[]
|
||||||
effects?: TeamEffects
|
effects?: TeamEffects
|
||||||
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */
|
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */
|
||||||
readingBash?: (command: string) => boolean
|
readingBash?: (command: string) => boolean
|
||||||
now?: () => number
|
now?: () => number
|
||||||
/** The lead's state as the team begins: working (its first prompt runs) or idle. */
|
/** The lead's state as the team begins: working (its first prompt runs) or idle. */
|
||||||
leadState?: "working" | "idle"
|
leadState?: "working" | "idle"
|
||||||
|
/** The main agent's mode (the session's). Default: auto. */
|
||||||
|
mode?: Mode
|
||||||
|
/** Steps a turn may take: the main agent's and a subagent's (for team_whoami). */
|
||||||
|
steps?: { main: number; sub: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The default rules' answer for a command in manual mode, with no rules of the person's. */
|
/** The default rules' answer for a command in manual mode, with no rules of the person's. */
|
||||||
@@ -111,10 +207,10 @@ export class Team {
|
|||||||
model: string
|
model: string
|
||||||
private agents = new Map<string, TeamAgent>()
|
private agents = new Map<string, TeamAgent>()
|
||||||
room: RoomMessage[] = []
|
room: RoomMessage[] = []
|
||||||
/** `task` helpers running in the team: who started each, and on which model. */
|
/** `task` subagents running in the team: who started each, and on which model. */
|
||||||
private helpers: { id: number; by: string; model: string }[] = []
|
private tasks: { id: number; by: string; model: string }[] = []
|
||||||
private seq = 0
|
private seq = 0
|
||||||
private helperSeq = 0
|
private taskSeq = 0
|
||||||
/** Whether the team is offered, fixed as the prompt is made (§2). */
|
/** Whether the team is offered, fixed as the prompt is made (§2). */
|
||||||
offered = false
|
offered = false
|
||||||
/** The setting as the lead was last told it. */
|
/** The setting as the lead was last told it. */
|
||||||
@@ -122,6 +218,11 @@ export class Team {
|
|||||||
/** Members the person stopped, not yet said to the lead. */
|
/** Members the person stopped, not yet said to the lead. */
|
||||||
private stoppedByPerson: string[] = []
|
private stoppedByPerson: string[] = []
|
||||||
private waits = 0
|
private waits = 0
|
||||||
|
/** Subagents that ended (stopped, done, out of context), in the order they ended. */
|
||||||
|
ended: TeamAgent[] = []
|
||||||
|
private idSeq = 0
|
||||||
|
private reqSeq = 0
|
||||||
|
requests: Request[] = []
|
||||||
|
|
||||||
constructor(private o: TeamOptions) {
|
constructor(private o: TeamOptions) {
|
||||||
this.cap = o.cap ?? TEAM.agents.cap
|
this.cap = o.cap ?? TEAM.agents.cap
|
||||||
@@ -130,7 +231,7 @@ export class Team {
|
|||||||
this.min = TEAM.agents.min
|
this.min = TEAM.agents.min
|
||||||
this.max = TEAM.agents.max
|
this.max = TEAM.agents.max
|
||||||
this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max)
|
this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max)
|
||||||
this.agents.set(LEAD, { name: LEAD, role: TEAM.read.lead_role, manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ })
|
this.agents.set(LEAD, { id: LEAD, name: LEAD, role: TEAM.read.lead_role, task: "", mode: o.mode ?? "auto", request: [], grants: [], findings: [], manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ })
|
||||||
this.fix()
|
this.fix()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +253,7 @@ export class Team {
|
|||||||
|
|
||||||
/** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */
|
/** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */
|
||||||
static check(min: number, max: number, cap: number): string | undefined {
|
static check(min: number, max: number, cap: number): string | undefined {
|
||||||
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `agents: 1 ≤ min ≤ max ≤ ${cap} (the cap) — whole numbers; min ${min}, max ${max} is outside`
|
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `Subagents: 1 ≤ Min ≤ Max ≤ ${cap} (the cap), whole numbers, this agent included; Min ${min}, Max ${max} is outside`
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,13 +266,22 @@ export class Team {
|
|||||||
return parallelFrom(this.o.capacity(this.model))
|
return parallelFrom(this.o.capacity(this.model))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §2: offered when subagents are on and the chat's model serves two at once — fixed as the
|
/** Spec 3.3.0 (subagents.md §4): whether a model could serve as a subagent now — the lead's own,
|
||||||
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
* with two slots or more, or another model off its one-model server — and whether it is the own. */
|
||||||
|
couldServe(): { any: boolean; own: boolean } {
|
||||||
|
const own = this.parallelNow() >= 2
|
||||||
|
const group = this.o.capacity(this.model).group
|
||||||
|
const other = (this.o.models?.() ?? []).some((r) => r !== this.model && (group === undefined || this.o.capacity(r).group !== group))
|
||||||
|
return { any: own || other, own }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §2: offered when subagents are on and a model could serve as a subagent — fixed as the prompt
|
||||||
|
* is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
||||||
fix(model = this.model, offered?: boolean): boolean {
|
fix(model = this.model, offered?: boolean): boolean {
|
||||||
this.model = model
|
this.model = model
|
||||||
const lead = this.agents.get(LEAD)
|
const lead = this.agents.get(LEAD)
|
||||||
if (lead) lead.model = model
|
if (lead) lead.model = model
|
||||||
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
|
this.offered = offered ?? (this.subagents && this.couldServe().any)
|
||||||
return this.offered
|
return this.offered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,9 +317,9 @@ export class Team {
|
|||||||
return this.agents.size > 1
|
return this.agents.size > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §3: the lead, every member, and every running `task` helper. */
|
/** §3: the lead, every member, and every running `task` subagent. */
|
||||||
size(): number {
|
size(): number {
|
||||||
return this.agents.size + this.helpers.length
|
return this.agents.size + this.tasks.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */
|
/** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */
|
||||||
@@ -222,8 +332,9 @@ export class Team {
|
|||||||
|
|
||||||
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
|
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
|
||||||
unavailable(): string | undefined {
|
unavailable(): string | undefined {
|
||||||
if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined
|
const could = this.couldServe().any
|
||||||
return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent
|
if ((this.max >= 2 && could) || this.hasMembers()) return undefined
|
||||||
|
return !could ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §3: what `task` answers in a team, or undefined when it may run (and then counts). */
|
/** §3: what `task` answers in a team, or undefined when it may run (and then counts). */
|
||||||
@@ -250,8 +361,14 @@ export class Team {
|
|||||||
/** What a call by `by` answers before it runs — the team tools' requirement, task's size, a
|
/** What a call by `by` answers before it runs — the team tools' requirement, task's size, a
|
||||||
* manager's work — or undefined when it goes on to its own checks (permission first). */
|
* manager's work — or undefined when it goes on to its own checks (permission first). */
|
||||||
refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined {
|
refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined {
|
||||||
if ((TEAM_TOOLS as readonly string[]).includes(tool)) return this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
if ((TEAM_TOOLS as readonly string[]).includes(tool)) {
|
||||||
if (tool === "task") return this.taskRefusal()
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
// Spec 3.4.0 (subagents.md §6): only the main agent adds, assigns, replaces, stops, decides.
|
||||||
|
if (by !== LEAD && MAIN_ONLY.includes(tool)) return TEXTS.only_main
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (tool === "task") return by !== LEAD ? TEXTS.only_main : this.taskRefusal()
|
||||||
if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager
|
if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -259,14 +376,14 @@ export class Team {
|
|||||||
// ── slots ───────────────────────────────────────────────────────────────────
|
// ── slots ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private busyOn(model: string): number {
|
private busyOn(model: string): number {
|
||||||
return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.helpers.filter((h) => h.model === model).length
|
return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.tasks.filter((h) => h.model === model).length
|
||||||
}
|
}
|
||||||
|
|
||||||
private free(model: string): boolean {
|
private free(model: string): boolean {
|
||||||
const group = this.o.capacity(model).group
|
const group = this.o.capacity(model).group
|
||||||
if (group !== undefined) {
|
if (group !== undefined) {
|
||||||
for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false
|
for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false
|
||||||
for (const h of this.helpers) if (h.model !== model && this.o.capacity(h.model).group === group) return false
|
for (const h of this.tasks) if (h.model !== model && this.o.capacity(h.model).group === group) return false
|
||||||
}
|
}
|
||||||
return this.busyOn(model) < parallelFrom(this.o.capacity(model))
|
return this.busyOn(model) < parallelFrom(this.o.capacity(model))
|
||||||
}
|
}
|
||||||
@@ -299,22 +416,22 @@ export class Team {
|
|||||||
return lead.state === "working"
|
return lead.state === "working"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A `task` helper started by `by` (it counts and holds a slot until helperDone). */
|
/** A `task` subagent started by `by` (it counts and holds a slot until taskDone). */
|
||||||
helperStart(by: string, model = this.model): number {
|
taskStart(by: string, model = this.model): number {
|
||||||
const id = ++this.helperSeq
|
const id = ++this.taskSeq
|
||||||
this.helpers.push({ id, by, model })
|
this.tasks.push({ id, by, model })
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
helperDone(id: number) {
|
taskDone(id: number) {
|
||||||
this.helpers = this.helpers.filter((h) => h.id !== id)
|
this.tasks = this.tasks.filter((h) => h.id !== id)
|
||||||
this.fillSlots()
|
this.fillSlots()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The first helper `by` started, done (the conformance area's `helper_done`). */
|
/** The first task subagent `by` started, done (the conformance area's `task_done`). */
|
||||||
helperDoneBy(by: string) {
|
taskDoneBy(by: string) {
|
||||||
const h = this.helpers.find((x) => x.by === by)
|
const h = this.tasks.find((x) => x.by === by)
|
||||||
if (h) this.helperDone(h.id)
|
if (h) this.taskDone(h.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── delivery ────────────────────────────────────────────────────────────────
|
// ── delivery ────────────────────────────────────────────────────────────────
|
||||||
@@ -323,6 +440,8 @@ export class Team {
|
|||||||
private deliver(name: string, text: string) {
|
private deliver(name: string, text: string) {
|
||||||
const a = this.agents.get(name)
|
const a = this.agents.get(name)
|
||||||
if (!a) return
|
if (!a) return
|
||||||
|
// Spec 3.4.0 §9: a paused subagent is given nothing new; the message stays in the room.
|
||||||
|
if (a.state === "paused") return
|
||||||
this.delivered.push({ name, text })
|
this.delivered.push({ name, text })
|
||||||
if (a.state === "working") return this.o.effects?.deliver(name, text)
|
if (a.state === "working") return this.o.effects?.deliver(name, text)
|
||||||
a.inbox.push(text)
|
a.inbox.push(text)
|
||||||
@@ -332,10 +451,11 @@ export class Team {
|
|||||||
/** What was delivered, to whom, since this was last read (for a front end and the conformance). */
|
/** What was delivered, to whom, since this was last read (for a front end and the conformance). */
|
||||||
delivered: { name: string; text: string }[] = []
|
delivered: { name: string; text: string }[] = []
|
||||||
|
|
||||||
private post(from: string, to: string[], recipients: string[], text: string, report: boolean): RoomMessage {
|
private post(from: string, to: string[], recipients: string[], text: string, report: boolean, kind: RoomKind = report ? "report" : "message", id = ""): RoomMessage {
|
||||||
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report }
|
const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report, kind }
|
||||||
this.room.push(m)
|
this.room.push(m)
|
||||||
const mark = fill(report ? TEAM.marks.report : TEAM.marks.message, { from, to: to.join(", ") })
|
const marks = TEAM.marks as Record<string, string>
|
||||||
|
const mark = fill(marks[kind]!, { from, to: to.join(", "), id })
|
||||||
for (const r of recipients) this.deliver(r, `${mark}\n${text}`)
|
for (const r of recipients) this.deliver(r, `${mark}\n${text}`)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -343,32 +463,49 @@ export class Team {
|
|||||||
// ── the tools ───────────────────────────────────────────────────────────────
|
// ── the tools ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
|
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
|
||||||
wouldRefuse(by: string, a: { name: string; goal?: string }): string | undefined {
|
wouldRefuse(by: string, a: { name: string; goal?: string; parent?: string; mode?: Mode; request?: string[] }): string | undefined {
|
||||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
if (no) return no
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
if (a.name === "all") return TEXTS.all_name
|
if (a.name === "all") return TEXTS.all_name
|
||||||
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
|
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
|
||||||
if (this.agents.has(a.name)) return fill(TEXTS.taken, { name: a.name })
|
if (this.agents.has(a.name) || a.name === TEAM.names.parent) return fill(TEXTS.taken, { name: a.name })
|
||||||
if (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
|
if (!this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
|
||||||
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
|
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
|
||||||
return undefined
|
return this.placeRefusal(a.parent, a.mode, a.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (§6, §7): `parent` must be the lead or a sub-manager; the mode no looser than its. */
|
||||||
|
private placeRefusal(parent: string | undefined, mode: Mode | undefined, request: string[] | undefined): string | undefined {
|
||||||
|
const p = this.agents.get(parent ?? LEAD)
|
||||||
|
if (!p) return fill(TEXTS.unknown, { name: parent!, names: this.names() })
|
||||||
|
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
|
||||||
|
const m = checkMode(p.mode, mode, request)
|
||||||
|
return "error" in m ? m.error : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** team_add (§4). `brief` is what the member's first turn is given. */
|
/** team_add (§4). `brief` is what the member's first turn is given. */
|
||||||
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
|
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } {
|
||||||
const no = this.wouldRefuse(by, a)
|
const no = this.wouldRefuse(by, a)
|
||||||
if (no) return { text: no }
|
if (no) return { text: no }
|
||||||
const lead = this.agents.get(LEAD)!
|
const lead = this.agents.get(LEAD)!
|
||||||
const goal = a.goal?.trim()
|
const goal = a.goal?.trim()
|
||||||
const caller = this.agents.get(by) ?? lead
|
const parent = this.agents.get(a.parent ?? LEAD)!
|
||||||
if (by === LEAD && goal) lead.goal = goal
|
if (goal) lead.goal = goal
|
||||||
|
const placed = checkMode(parent.mode, a.mode, a.request) as { mode: Mode; request: ToolClass[] }
|
||||||
const member: TeamAgent = {
|
const member: TeamAgent = {
|
||||||
|
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
role: a.role.trim().split("\n")[0]!,
|
role: a.role.trim().split("\n")[0]!,
|
||||||
manager: by,
|
task: a.task,
|
||||||
|
mode: placed.mode,
|
||||||
|
request: placed.request,
|
||||||
|
grants: [],
|
||||||
|
findings: [],
|
||||||
|
manager: parent.name,
|
||||||
leads: a.lead === true,
|
leads: a.lead === true,
|
||||||
goal: goal || caller.goal,
|
goal: goal || parent.goal || lead.goal,
|
||||||
model: a.model ?? caller.model,
|
model: a.model ?? lead.model,
|
||||||
agent: a.agent ?? "general",
|
agent: a.agent ?? "general",
|
||||||
state: "finished",
|
state: "finished",
|
||||||
inbox: [],
|
inbox: [],
|
||||||
@@ -386,7 +523,13 @@ export class Team {
|
|||||||
send(by: string, a: { to: string[] | string; message: string }): string {
|
send(by: string, a: { to: string[] | string; message: string }): string {
|
||||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
if (no) return no
|
if (no) return no
|
||||||
const list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)]
|
let list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)]
|
||||||
|
// Spec 3.4.0: `parent` is the sender's parent.
|
||||||
|
if (list.includes(TEAM.names.parent)) {
|
||||||
|
const mgr = this.agents.get(by)?.manager
|
||||||
|
if (!mgr) return TEXTS.no_parent
|
||||||
|
list = [...new Set(list.map((n) => (n === TEAM.names.parent ? mgr : n)))]
|
||||||
|
}
|
||||||
const all = list.includes("all")
|
const all = list.includes("all")
|
||||||
const others = [...this.agents.keys()].filter((n) => n !== by)
|
const others = [...this.agents.keys()].filter((n) => n !== by)
|
||||||
if (all) {
|
if (all) {
|
||||||
@@ -439,12 +582,21 @@ export class Team {
|
|||||||
fill(R.agent, {
|
fill(R.agent, {
|
||||||
indent: " ".repeat(depth(a)),
|
indent: " ".repeat(depth(a)),
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
id: a.id,
|
||||||
role: a.role,
|
role: a.role,
|
||||||
state: R.states[a.state],
|
state: R.states[a.state],
|
||||||
model: a.model,
|
model: a.model,
|
||||||
|
mode: a.request.length ? `${a.mode} ${fill(R.request, { kinds: a.request.join(", ") })}` : a.mode,
|
||||||
|
context: a.context && a.context.window > 0 ? fill(R.context, { percent: Math.floor((a.context.used * 100) / a.context.window) }) : "—",
|
||||||
last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—",
|
last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const open = this.openSlots()
|
||||||
|
if (open) lines.push(fill(R.slots, { n: open }))
|
||||||
|
if (this.ended.length) {
|
||||||
|
lines.push(R.ended)
|
||||||
|
for (const a of this.ended) lines.push(fill(R.ended_agent, { name: a.name, id: a.id, role: a.role, state: R.states[a.state], findings: a.findings.length }))
|
||||||
|
}
|
||||||
const { messages, more } = this.readRoom(by, since)
|
const { messages, more } = this.readRoom(by, since)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
if (!messages.length) lines.push(fill(R.none, { since }))
|
if (!messages.length) lines.push(fill(R.none, { since }))
|
||||||
@@ -464,6 +616,7 @@ export class Team {
|
|||||||
stopCmd(by: string, name: string): string {
|
stopCmd(by: string, name: string): string {
|
||||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
if (no) return no
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
if (name === "all") {
|
if (name === "all") {
|
||||||
const under = this.membersOf(by)
|
const under = this.membersOf(by)
|
||||||
if (!under.length) return TEXTS.nobody_to_stop
|
if (!under.length) return TEXTS.nobody_to_stop
|
||||||
@@ -487,10 +640,26 @@ export class Team {
|
|||||||
return gone.map((a) => a.name)
|
return gone.map((a) => a.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove(gone: TeamAgent[]) {
|
private remove(gone: TeamAgent[], end: EndState = "stopped") {
|
||||||
for (const a of gone) {
|
for (const a of gone) {
|
||||||
this.agents.delete(a.name)
|
this.agents.delete(a.name)
|
||||||
this.o.effects?.stop(a.name)
|
a.state = end
|
||||||
|
a.endState = end
|
||||||
|
a.endedAt = this.now()
|
||||||
|
a.inbox = []
|
||||||
|
a.brief = undefined
|
||||||
|
this.ended.push(a)
|
||||||
|
for (const r of this.requests) if (r.from === a.name) r.open = false
|
||||||
|
if (end === "done") this.o.effects?.end?.(a.name)
|
||||||
|
else if (end === "stopped") this.o.effects?.stop(a.name)
|
||||||
|
// Spec 3.4.0: the subagents under one that ended by itself move under its parent until the
|
||||||
|
// main agent replaces it (team_replace moves them back under the replacement).
|
||||||
|
if (end !== "stopped")
|
||||||
|
for (const c of this.agents.values())
|
||||||
|
if (c.manager === a.name) {
|
||||||
|
c.manager = a.manager ?? LEAD
|
||||||
|
c.movedFrom = a.name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.fillSlots()
|
this.fillSlots()
|
||||||
}
|
}
|
||||||
@@ -499,7 +668,16 @@ export class Team {
|
|||||||
* stop, steps, budget, or `error — <text>`. */
|
* stop, steps, budget, or `error — <text>`. */
|
||||||
finish(name: string, reply = "", reason = "stop") {
|
finish(name: string, reply = "", reason = "stop") {
|
||||||
const a = this.agents.get(name)
|
const a = this.agents.get(name)
|
||||||
if (!a || a.state !== "working") return
|
if (!a) return
|
||||||
|
// Spec 3.4.0 §9: the final turn after its context filled has ended — it is out of context.
|
||||||
|
if (a.state === "paused") {
|
||||||
|
const gone = fill(TEXTS.gone, { name: a.name, id: a.id })
|
||||||
|
const text = reply.trim() ? `${gone}\n\n${reply.trim()}` : gone
|
||||||
|
if (a.manager) this.post(name, [a.manager], [a.manager], text, true)
|
||||||
|
this.remove([a], "out_of_context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (a.state !== "working") return
|
||||||
a.state = name === LEAD ? "idle" : "finished"
|
a.state = name === LEAD ? "idle" : "finished"
|
||||||
a.ended = this.now()
|
a.ended = this.now()
|
||||||
if (a.manager) {
|
if (a.manager) {
|
||||||
@@ -534,6 +712,241 @@ export class Team {
|
|||||||
this.stoppedByPerson = []
|
this.stoppedByPerson = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── spec 3.4.0: the main agent's tree (subagents.md §5–§13) ─────────────────────
|
||||||
|
|
||||||
|
/** A subagent by name (live first, else the latest that ended) or by id. */
|
||||||
|
find(nameOrId: string): TeamAgent | undefined {
|
||||||
|
return this.agents.get(nameOrId) ?? [...this.agents.values()].find((a) => a.id === nameOrId) ?? [...this.ended].reverse().find((a) => a.name === nameOrId || a.id === nameOrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open slots (§6): min − 1 places for subagents not added yet, while min ≥ 2 and offered. */
|
||||||
|
openSlots(): number {
|
||||||
|
if (!this.offered || this.min < 2) return 0
|
||||||
|
return Math.max(0, this.min - 1 - (this.agents.size - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main agent's mode changed: stricter reaches down the tree, looser changes nobody (§7). */
|
||||||
|
setMainMode(mode: Mode) {
|
||||||
|
this.agents.get(LEAD)!.mode = mode
|
||||||
|
for (const a of this.tree()) if (a.manager) a.mode = strictest(a.mode, this.agents.get(a.manager)?.mode ?? mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its engine's context use, and its progress, as last said. */
|
||||||
|
setContext(name: string, used: number, window: number) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (a) a.context = { used, window }
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(name: string, p: Progress | null) {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (a) a.progress = p
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_whoami (§8). */
|
||||||
|
whoami(by: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
|
||||||
|
const W = TEAM.whoami.lines
|
||||||
|
const steps = by === LEAD ? (this.o.steps?.main ?? LOOP.steps.default) : (this.o.steps?.sub ?? LOOP.jobs.subagent_steps.default)
|
||||||
|
const lines = [fill(W.who, { name: a.name, id: a.id, role: a.role })]
|
||||||
|
if (a.manager) lines.push(fill(W.parent, { parent: a.manager }))
|
||||||
|
if (a.manager) lines.push(fill(W.task, { task: a.task.split("\n")[0]! }))
|
||||||
|
lines.push(fill(W.mode, { mode: a.mode }))
|
||||||
|
if (a.request.length) lines.push(fill(W.request, { kinds: a.request.join(", ") }))
|
||||||
|
lines.push(fill(W.model, { model: a.model }))
|
||||||
|
lines.push(fill(W.limits, { size: this.size(), max: this.max, steps, spawn: by === LEAD ? W.spawn_yes : W.spawn_no }))
|
||||||
|
if (a.context && a.context.window > 0) lines.push(fill(W.context, { used: a.context.used, window: a.context.window, percent: Math.floor((a.context.used * 100) / a.context.window) }))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_findings write (§8). */
|
||||||
|
noteFinding(by: string, text: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() })
|
||||||
|
const n = a.findings.length + 1
|
||||||
|
a.findings.push({ n, text: text.trim(), time: this.now() })
|
||||||
|
return fill(TEXTS.noted, { n })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `by` may read `target`'s findings: its own, one in its subtree, or the lead anyone's. */
|
||||||
|
private mayRead(by: string, target: TeamAgent): boolean {
|
||||||
|
if (by === LEAD || target.name === by) return true
|
||||||
|
let up: string | null | undefined = target.endState ? (target.movedFrom ?? target.manager) : target.manager
|
||||||
|
for (let guard = 0; up && guard < 1000; guard++) {
|
||||||
|
if (up === by) return true
|
||||||
|
up = this.agents.get(up)?.manager ?? this.find(up)?.manager
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_findings read (§8). */
|
||||||
|
readFindings(by: string, of?: string): string {
|
||||||
|
const target = of ? this.find(of) : this.agents.get(by)
|
||||||
|
if (!target) return fill(TEXTS.unknown, { name: of ?? by, names: this.names() })
|
||||||
|
if (!this.mayRead(by, target)) return TEXTS.not_yours_findings
|
||||||
|
const lines = [fill(TEAM.findings.header, { name: target.name, id: target.id })]
|
||||||
|
if (!target.findings.length) lines.push(TEXTS.no_findings)
|
||||||
|
for (const f of target.findings) lines.push(fill(TEAM.findings.item, { n: f.n, text: f.text }))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_finish (§8): the report to its parent, and it ends (done). */
|
||||||
|
finishTask(by: string, report: string): string {
|
||||||
|
const a = this.agents.get(by)
|
||||||
|
if (!a || !a.manager) return TEXTS.no_parent
|
||||||
|
this.post(by, [a.manager], [a.manager], report.trim() || TEXTS.no_report, true)
|
||||||
|
const to = a.manager
|
||||||
|
this.remove([a], "done")
|
||||||
|
return fill(TEXTS.finished, { to })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its context is full (§9): paused, and given one final turn. The text of that turn. */
|
||||||
|
pause(name: string): string | undefined {
|
||||||
|
const a = this.agents.get(name)
|
||||||
|
if (!a || a.name === LEAD || a.state === "paused") return undefined
|
||||||
|
a.state = "paused"
|
||||||
|
a.inbox = []
|
||||||
|
const text = `${fill(TEAM.marks.context_full, { to: name })}\n${fill(TEXTS.context_full, { steps: TEAM.context.final_steps })}`
|
||||||
|
this.delivered.push({ name, text })
|
||||||
|
this.o.effects?.final?.(name, text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_assign (§8). */
|
||||||
|
assign(by: string, a: { name: string; task: string; role?: string; parent?: string; mode?: Mode }): string {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
const t = this.agents.get(a.name)
|
||||||
|
if (!t || t.name === LEAD) return this.find(a.name)?.endState ? fill(TEXTS.has_ended, { name: a.name }) : fill(TEXTS.unknown, { name: a.name, names: this.names() })
|
||||||
|
const parentName = a.parent ?? t.manager!
|
||||||
|
const p = this.agents.get(parentName)
|
||||||
|
if (!p) return fill(TEXTS.unknown, { name: parentName, names: this.names() })
|
||||||
|
if (!p.leads) return fill(TEXTS.not_manager, { name: p.name })
|
||||||
|
if (this.subtree(t.name).some((x) => x.name === p.name)) return fill(TEXTS.cycle, { name: t.name, parent: p.name })
|
||||||
|
let mode = strictest(t.mode, p.mode)
|
||||||
|
let request = t.request
|
||||||
|
if (a.mode) {
|
||||||
|
const m = checkMode(p.mode, a.mode, t.request.length && a.mode !== p.mode ? t.request : undefined)
|
||||||
|
if ("error" in m) return m.error
|
||||||
|
mode = m.mode
|
||||||
|
request = m.mode === p.mode ? [] : t.request
|
||||||
|
}
|
||||||
|
t.task = a.task
|
||||||
|
if (a.role?.trim()) t.role = a.role.trim().split("\n")[0]!
|
||||||
|
t.manager = p.name
|
||||||
|
t.movedFrom = undefined
|
||||||
|
t.mode = mode
|
||||||
|
t.request = request
|
||||||
|
for (const c of this.subtree(t.name).slice(1)) c.mode = strictest(c.mode, this.agents.get(c.manager!)!.mode)
|
||||||
|
this.post(LEAD, [t.name], [t.name], a.task, false, "assignment")
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.assigned, { name: t.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_replace (§8): the same name, position, parent, mode and model; a new id; briefed with
|
||||||
|
* the task and the old one's findings. */
|
||||||
|
replace(by: string, a: { name: string; task?: string }): { text: string; added?: TeamAgent } {
|
||||||
|
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||||
|
if (no) return { text: no }
|
||||||
|
if (by !== LEAD) return { text: TEXTS.only_main }
|
||||||
|
const old = this.agents.get(a.name) ?? [...this.ended].reverse().find((x) => x.name === a.name)
|
||||||
|
if (!old || old.name === LEAD) return { text: fill(TEXTS.unknown, { name: a.name, names: this.names() }) }
|
||||||
|
const live = this.agents.get(a.name) === old
|
||||||
|
if (!live && this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
|
||||||
|
if (live) {
|
||||||
|
// It alone ends: the subagents under it move to the replacement.
|
||||||
|
for (const c of this.membersOf(old.name)) c.movedFrom = old.name
|
||||||
|
for (const c of this.membersOf(old.name)) c.manager = old.manager ?? LEAD
|
||||||
|
this.remove([old], "stopped")
|
||||||
|
}
|
||||||
|
const manager = old.movedFrom ?? old.manager
|
||||||
|
const parent = manager && this.agents.get(manager) ? manager : LEAD
|
||||||
|
const findings = old.findings.length ? old.findings.map((f) => fill(TEAM.findings.item, { n: f.n, text: f.text })).join("\n") : TEXTS.no_findings
|
||||||
|
const brief = fill(TEXTS.replace_brief, { task: a.task?.trim() || old.task, name: old.name, id: old.id, findings })
|
||||||
|
const p = this.agents.get(parent)!
|
||||||
|
const fresh: TeamAgent = {
|
||||||
|
...old,
|
||||||
|
id: `${TEAM.names.id_prefix}${++this.idSeq}`,
|
||||||
|
task: a.task?.trim() || old.task,
|
||||||
|
mode: strictest(old.mode, p.mode),
|
||||||
|
grants: [],
|
||||||
|
findings: [],
|
||||||
|
context: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
manager: parent,
|
||||||
|
state: "finished",
|
||||||
|
inbox: [],
|
||||||
|
brief,
|
||||||
|
seq: this.seq++,
|
||||||
|
endState: undefined,
|
||||||
|
endedAt: undefined,
|
||||||
|
movedFrom: undefined,
|
||||||
|
ended: undefined,
|
||||||
|
waitSince: undefined,
|
||||||
|
jobId: undefined,
|
||||||
|
}
|
||||||
|
this.agents.set(fresh.name, fresh)
|
||||||
|
for (const c of this.agents.values())
|
||||||
|
if (c.movedFrom === old.name) {
|
||||||
|
c.manager = fresh.name
|
||||||
|
c.movedFrom = undefined
|
||||||
|
}
|
||||||
|
this.want(fresh)
|
||||||
|
this.fillSlots()
|
||||||
|
return { text: fill(TEXTS.replaced, { name: fresh.name, id: fresh.id, size: this.size(), max: this.max }), added: fresh }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_request (§7): `cls` is the tool's risk class. */
|
||||||
|
request(by: string, a: { tool: string; pattern: string; why: string }, cls: ToolClass | undefined): string {
|
||||||
|
const t = this.agents.get(by)
|
||||||
|
if (!t || !t.manager) return TEXTS.no_parent
|
||||||
|
if (!cls || !t.request.includes(cls)) return fill(TEXTS.not_requestable, { tool: a.tool, kinds: t.request.join(", ") || "none" })
|
||||||
|
const id = `${TEAM.request.id_prefix}${++this.reqSeq}`
|
||||||
|
this.requests.push({ id, from: by, tool: a.tool, pattern: a.pattern, why: a.why, open: true })
|
||||||
|
this.post(by, [LEAD], [LEAD], fill(TEXTS.request_body, { tool: a.tool, pattern: a.pattern, why: a.why }), false, "request", id)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.requested, { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** team_decide (§7): a grant for the subagent when allowed; it is told either way. `permission`:
|
||||||
|
* the tool's permission key. */
|
||||||
|
decide(by: string, a: { id: string; allow: boolean; note?: string }, permission?: (tool: string) => string | undefined): string {
|
||||||
|
if (by !== LEAD) return TEXTS.only_main
|
||||||
|
const r = this.requests.find((x) => x.id === a.id && x.open)
|
||||||
|
const t = r && this.agents.get(r.from)
|
||||||
|
if (!r || !t) return fill(TEXTS.no_request, { id: a.id })
|
||||||
|
r.open = false
|
||||||
|
if (a.allow) t.grants.push({ tool: r.tool, permission: permission?.(r.tool) ?? r.tool, pattern: r.pattern })
|
||||||
|
const head = fill(a.allow ? TEXTS.decision_allowed : TEXTS.decision_refused, { tool: r.tool, pattern: r.pattern })
|
||||||
|
this.post(LEAD, [t.name], [t.name], a.note?.trim() ? `${head}\n\n${a.note.trim()}` : head, false, "decision", r.id)
|
||||||
|
this.fillSlots()
|
||||||
|
return fill(TEXTS.decided, { decision: a.allow ? TEXTS.allowed : TEXTS.refused, id: r.id, name: t.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree as clients show it (§12). */
|
||||||
|
info(): TreeInfo {
|
||||||
|
const row = (a: TeamAgent): TreeAgent => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
position: a.role,
|
||||||
|
parent: a.manager,
|
||||||
|
state: a.state,
|
||||||
|
model: a.model,
|
||||||
|
mode: a.mode,
|
||||||
|
request: [...a.request],
|
||||||
|
manager: a.name === LEAD ? true : a.leads,
|
||||||
|
...(a.manager ? { task: a.task } : {}),
|
||||||
|
...(a.context ? { context: { ...a.context } } : {}),
|
||||||
|
progress: a.progress ?? null,
|
||||||
|
findings: a.findings.length,
|
||||||
|
messages: this.room.filter((m) => m.from === a.name || m.recipients.includes(a.name)).length,
|
||||||
|
...(a.endedAt !== undefined ? { ended: a.endedAt } : {}),
|
||||||
|
})
|
||||||
|
return { min: this.min, max: this.max, size: this.size(), open_slots: this.openSlots(), agents: this.tree().map(row), ended: this.ended.map(row) }
|
||||||
|
}
|
||||||
|
|
||||||
// ── what the lead is told ───────────────────────────────────────────────────
|
// ── what the lead is told ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
||||||
@@ -545,7 +958,7 @@ export class Team {
|
|||||||
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
|
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
|
||||||
this.told = { min: this.min, max: this.max }
|
this.told = { min: this.min, max: this.max }
|
||||||
if (this.max === 1) out.push(N.team_one)
|
if (this.max === 1) out.push(N.team_one)
|
||||||
else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : [])].join("\n"))
|
else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : []), ...(this.couldServe().own ? [] : [N.team_own])].join("\n"))
|
||||||
}
|
}
|
||||||
if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") }))
|
if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") }))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
|
|||||||
import { planUpdateTool } from "./plan_update.ts"
|
import { planUpdateTool } from "./plan_update.ts"
|
||||||
import { planWriteTool } from "./plan_write.ts"
|
import { planWriteTool } from "./plan_write.ts"
|
||||||
import { taskTool } from "./task.ts"
|
import { taskTool } from "./task.ts"
|
||||||
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
|
import { teamAddTool, teamAssignTool, teamDecideTool, teamFindingsTool, teamFinishTool, teamReadTool, teamReplaceTool, teamRequestTool, teamSendTool, teamStopTool, teamWhoamiTool } from "./team_tools.ts"
|
||||||
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
||||||
import { webFetchTool, webSearchTool } from "./web.ts"
|
import { webFetchTool, webSearchTool } from "./web.ts"
|
||||||
import { memoryTool } from "./memory.ts"
|
import { memoryTool } from "./memory.ts"
|
||||||
@@ -46,6 +46,14 @@ export const BUILTIN_TOOLS: Tool[] = [
|
|||||||
teamSendTool,
|
teamSendTool,
|
||||||
teamReadTool,
|
teamReadTool,
|
||||||
teamStopTool,
|
teamStopTool,
|
||||||
|
// Spec 3.4.0 (subagents.md §8).
|
||||||
|
teamWhoamiTool,
|
||||||
|
teamFindingsTool,
|
||||||
|
teamFinishTool,
|
||||||
|
teamRequestTool,
|
||||||
|
teamDecideTool,
|
||||||
|
teamAssignTool,
|
||||||
|
teamReplaceTool,
|
||||||
planWriteTool,
|
planWriteTool,
|
||||||
planUpdateTool,
|
planUpdateTool,
|
||||||
tasksTool,
|
tasksTool,
|
||||||
|
|||||||
+116
-2
@@ -1,5 +1,6 @@
|
|||||||
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the
|
// The team tools (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13; tools/team_*.json):
|
||||||
// room, read the team and the room, stop a member. The rules are team/team.ts; these are its calls
|
// add a subagent, send through the room, read the tree and the room, stop one — and from 3.4.0
|
||||||
|
// whoami, findings, finish, request and decide, assign and replace. The rules are team/team.ts; these are its calls
|
||||||
// for the agent the context names (`ctx.team.as`).
|
// for the agent the context names (`ctx.team.as`).
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
||||||
@@ -37,6 +38,9 @@ export const teamAddTool = defineTool({
|
|||||||
lead: z.boolean().optional(),
|
lead: z.boolean().optional(),
|
||||||
agent: z.string().optional(),
|
agent: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
parent: z.string().optional(),
|
||||||
|
mode: z.enum(["auto", "edit", "manual", "plan"]).optional(),
|
||||||
|
request: z.array(z.enum(["read", "write", "execute"])).optional(),
|
||||||
}),
|
}),
|
||||||
invalid: badName,
|
invalid: badName,
|
||||||
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
|
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
|
||||||
@@ -92,3 +96,113 @@ export const teamStopTool = defineTool({
|
|||||||
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
|
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const teamWhoamiTool = defineTool({
|
||||||
|
...described("team_whoami"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_whoami",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({}),
|
||||||
|
permission: () => ({ permission: "team_whoami", class: "read", patterns: ["*"] }),
|
||||||
|
async run(_a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
return { output: t.runtime.team.whoami(t.as), title: t.as }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamFindingsTool = defineTool({
|
||||||
|
...described("team_findings"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_findings",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ action: z.enum(["write", "read"]), text: z.string().optional(), of: z.string().optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_findings", class: "interact", patterns: [a.action] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
if (a.action === "write") {
|
||||||
|
if (!a.text?.trim()) throw new ToolError("write needs `text`: the finding.")
|
||||||
|
const output = t.runtime.team.noteFinding(t.as, a.text)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: "finding noted" }
|
||||||
|
}
|
||||||
|
return { output: t.runtime.team.readFindings(t.as, a.of), title: `findings of ${a.of ?? t.as}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamFinishTool = defineTool({
|
||||||
|
...described("team_finish"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_finish",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ report: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_finish", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.finishTask(t.as, a.report)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: "finished" }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamRequestTool = defineTool({
|
||||||
|
...described("team_request"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_request",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ tool: z.string().min(1), pattern: z.string().min(1), why: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_request", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const before = t.runtime.team.requests.length
|
||||||
|
const output = t.runtime.team.request(t.as, a, t.runtime.classOf(a.tool))
|
||||||
|
t.runtime.notify()
|
||||||
|
const asked = t.runtime.team.requests.length > before
|
||||||
|
return { output, title: asked ? `asked for ${a.tool} ${a.pattern}` : "not asked", isError: !asked }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamDecideTool = defineTool({
|
||||||
|
...described("team_decide"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_decide",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ id: z.string().min(1), allow: z.boolean(), note: z.string().optional() }),
|
||||||
|
permission: () => ({ permission: "team_decide", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.decide(t.as, a, (tool) => t.runtime.permissionKeyOf(tool))
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: `${a.allow ? "allowed" : "refused"} ${a.id}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamAssignTool = defineTool({
|
||||||
|
...described("team_assign"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_assign",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1), task: z.string().min(1), role: z.string().optional(), parent: z.string().optional(), mode: z.enum(["auto", "edit", "manual", "plan"]).optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_assign", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const output = t.runtime.team.assign(t.as, a)
|
||||||
|
t.runtime.syncModes()
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: `to ${a.name}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamReplaceTool = defineTool({
|
||||||
|
...described("team_replace"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_replace",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1), task: z.string().optional() }),
|
||||||
|
permission: (a) => ({ permission: "team_replace", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const r = t.runtime.team.replace(t.as, a)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output: r.text, title: r.added ? `${a.name} → ${r.added.id}` : "not replaced", isError: !r.added }
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const sendToJob = (text: string) => {
|
const sendToJob = (text: string) => {
|
||||||
const id = entered()!
|
const id = entered()!
|
||||||
const job = app.registry.get(app.jobsSessionId(), id)
|
const job = app.registry.get(app.jobsSessionId(), id)
|
||||||
|
// Spec 3.4.0 §11: a subagent of the tree is read-only; the person speaks through the main agent.
|
||||||
|
if (job?.team) return view.info(`${id} is ${job.team}, a subagent of the main agent: its session is read-only — tell the main agent, or /team stop ${job.team}`, "warn")
|
||||||
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
|
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
|
||||||
if (job.state === "running") {
|
if (job.state === "running") {
|
||||||
job.subagent.send(text)
|
job.subagent.send(text)
|
||||||
|
|||||||
+78
-23
@@ -11,11 +11,13 @@ import { doRelease, planRelease } from "../git/release.ts"
|
|||||||
import { instructionFiles } from "../prompt/assemble.ts"
|
import { instructionFiles } from "../prompt/assemble.ts"
|
||||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||||
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
|
import { TEAM } from "../team/team.ts"
|
||||||
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||||
import { git } from "../git/run.ts"
|
import { git } from "../git/run.ts"
|
||||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||||
import type { Ctx } from "./context.ts"
|
import type { Ctx } from "./context.ts"
|
||||||
import type { DialogSpec } from "./components/dialog.tsx"
|
import type { DialogSpec, Option } from "./components/dialog.tsx"
|
||||||
import { deleteEverywhere } from "../acp/share.ts"
|
import { deleteEverywhere } from "../acp/share.ts"
|
||||||
import { themeNames } from "./theme.ts"
|
import { themeNames } from "./theme.ts"
|
||||||
import { render } from "../plan/plan.ts"
|
import { render } from "../plan/plan.ts"
|
||||||
@@ -85,7 +87,6 @@ const pendingCodes = new Map<string, (text: string) => void>()
|
|||||||
|
|
||||||
const AGENT_TEMPLATE = (name: string) => `---
|
const AGENT_TEMPLATE = (name: string) => `---
|
||||||
description: what ${name} is for — the model reads this when it picks a subagent
|
description: what ${name} is for — the model reads this when it picks a subagent
|
||||||
# model: provider/model # run it on another of the server's models (default: the session's)
|
|
||||||
# mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours
|
# mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours
|
||||||
# tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have
|
# tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have
|
||||||
# isolation: worktree # work in a git checkout and branch of its own
|
# isolation: worktree # work in a git checkout and branch of its own
|
||||||
@@ -96,23 +97,44 @@ report is all the session's model sees of your work.
|
|||||||
|
|
||||||
const TEAM_ROW = "\u0000team"
|
const TEAM_ROW = "\u0000team"
|
||||||
|
|
||||||
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */
|
/** The Subagents setting as one range from 1 to the cap with two handles, Min and Max (harness
|
||||||
|
* team.md §2): the numbers count this agent, so 1 is this agent alone. */
|
||||||
|
function teamRange(c: CommandCtx): Option {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const S = SUBAGENTS.setting
|
||||||
|
return {
|
||||||
|
label: S.label,
|
||||||
|
value: TEAM_ROW,
|
||||||
|
// The session's model cannot serve subagents itself: said where the size is set (subagents.md §4).
|
||||||
|
description: t.couldServe().own ? S.short : S.own,
|
||||||
|
range: { from: 1, to: t.cap, lo: t.min, hi: t.max, names: [S.min, S.max], onSubmit: (lo, hi) => applyTeamSize(c, `${lo} ${hi}`) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTeamSize = (c: CommandCtx, v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
|
||||||
|
|
||||||
|
/** The team's size for this session: the range, a few usual pairs, or any typed as `<min> <max>`. */
|
||||||
function openTeamSize(c: CommandCtx) {
|
function openTeamSize(c: CommandCtx) {
|
||||||
const t = c.app.team.team
|
const t = c.app.team.team
|
||||||
const pairs = [
|
const pairs = [
|
||||||
[1, 1, "one agent: no team, no helper"],
|
[1, 1, "this agent alone: no subagents"],
|
||||||
[1, 4, "you decide; up to three members"],
|
[1, 4, "this agent decides; up to three subagents"],
|
||||||
[2, 4, "a team from the start"],
|
[2, 4, "subagents from the start"],
|
||||||
[1, 8, "you decide; up to seven members"],
|
[1, 8, "this agent decides; up to seven subagents"],
|
||||||
[3, 8, "a team of at least three"],
|
[3, 8, "at least two subagents"],
|
||||||
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
|
[1, Math.min(16, t.cap), "many subagents, with sub-managers"],
|
||||||
] as const
|
] as const
|
||||||
|
const S = SUBAGENTS.setting
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const options = pairs
|
const options = pairs
|
||||||
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
|
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
|
||||||
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
|
.map(([min, max, what]) => ({ label: `${S.min} ${min}, ${S.max} ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
|
||||||
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
|
c.openDialog({
|
||||||
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } })
|
title: `${S.label} — ${S.min} ${t.min}, ${S.max} ${t.max} (1 to ${t.cap}, this agent included)`,
|
||||||
|
options: [teamRange(c), ...options],
|
||||||
|
onSelect: (v) => applyTeamSize(c, v),
|
||||||
|
custom: { label: "set to <min> <max>", onSubmit: (v) => applyTeamSize(c, v) },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COMMANDS: Command[] = [
|
export const COMMANDS: Command[] = [
|
||||||
@@ -146,7 +168,7 @@ export const COMMANDS: Command[] = [
|
|||||||
const describe = (a: (typeof list)[number]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${a.name} (${a.source}) — ${a.description}`,
|
||||||
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
if (named) return c.view.info(describe(named))
|
if (named) return c.view.info(describe(named))
|
||||||
c.view.info(
|
c.view.info(
|
||||||
@@ -215,10 +237,10 @@ export const COMMANDS: Command[] = [
|
|||||||
c.setEffort(v as Level)
|
c.setEffort(v as Level)
|
||||||
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
||||||
}
|
}
|
||||||
// The team's size sits beside the effort (team.md §2: shown only where a team is offered).
|
// The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where
|
||||||
const team = c.app.team.team
|
// subagents are offered).
|
||||||
const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : []
|
const agentsRow = c.app.team.team.offered ? [teamRange(c)] : []
|
||||||
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
|
const choose = (v: string) => set(v)
|
||||||
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
|
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
|
||||||
if (arg) return set(arg)
|
if (arg) return set(arg)
|
||||||
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
|
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
|
||||||
@@ -241,12 +263,29 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "team",
|
name: "team",
|
||||||
args: "[size [<min> <max>] | stop [<name>]]",
|
args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
|
||||||
description: "the team: its agents and the room; its size; stop a member",
|
description: "subagents: the tree and the room; how many; stop one; their findings",
|
||||||
run(c, arg) {
|
run(c, arg) {
|
||||||
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
|
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
|
||||||
const words = arg.trim().split(/\s+/).filter(Boolean)
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
|
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
|
||||||
|
// Spec 3.4.0 §11: a subagent's session, read-only, live or after it ended.
|
||||||
|
if (words[0] === "open") {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const all = [...t.tree().filter((a) => a.manager !== null), ...t.ended]
|
||||||
|
const open = (key: string) => {
|
||||||
|
const a = t.find(key)
|
||||||
|
if (!a?.jobId) return c.view.info(`no subagent ${key} with a session here — /team lists them`, "warn")
|
||||||
|
c.enterJob(a.jobId)
|
||||||
|
}
|
||||||
|
if (words[1]) return open(words[1])
|
||||||
|
if (!all.length) return c.view.info("no subagents yet")
|
||||||
|
return c.openDialog({
|
||||||
|
title: "A subagent's session — read-only",
|
||||||
|
options: all.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, description: `${a.role} · ${TEAM.read.states[a.state]}` })),
|
||||||
|
onSelect: open,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (words[0] === "stop" && words.length === 1) {
|
if (words[0] === "stop" && words.length === 1) {
|
||||||
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
||||||
if (!members.length) return c.view.info("the team has no members")
|
if (!members.length) return c.view.info("the team has no members")
|
||||||
@@ -687,8 +726,24 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "context",
|
name: "context",
|
||||||
description: "where the context window goes",
|
args: "[<subagent>]",
|
||||||
run(c) {
|
description: "where the context window goes — the session's, or a subagent's",
|
||||||
|
run(c, arg) {
|
||||||
|
// Spec 3.4.0 §12: a subagent's context, by its name or id.
|
||||||
|
const who = arg.trim()
|
||||||
|
if (who) {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const a = t.find(who)
|
||||||
|
if (!a) return c.view.info(`no subagent ${who} — /team lists them`, "warn")
|
||||||
|
const h = c.app.team.handle(a.name)
|
||||||
|
if (!h || a.endState) {
|
||||||
|
const u = a.context
|
||||||
|
return c.view.info(u ? `${a.name} (${a.id}): ${u.used} of ${u.window} tokens (${Math.floor((u.used * 100) / u.window)}%) when it ${a.endState ? TEAM.read.states[a.state] : "last said"}` : `${a.name} (${a.id}): no context use known yet`)
|
||||||
|
}
|
||||||
|
const se = h.engine
|
||||||
|
const sys = se.systemPrompt()
|
||||||
|
return c.view.info(`${a.name} (${a.id}) — ${a.role}\n${renderBreakdown(contextBreakdown(se, sys, [], undefined, se.o.tools))}`)
|
||||||
|
}
|
||||||
const e = c.app.engine
|
const e = c.app.engine
|
||||||
// The prompt the session sends (made now if no request has gone yet).
|
// The prompt the session sends (made now if no request has gone yet).
|
||||||
const system = e.systemPrompt()
|
const system = e.systemPrompt()
|
||||||
@@ -1006,7 +1061,7 @@ export const COMMANDS: Command[] = [
|
|||||||
? [...(e.def.values ?? [])]
|
? [...(e.def.values ?? [])]
|
||||||
: []
|
: []
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}`,
|
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
|
||||||
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
|
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
|
||||||
onSelect: (v) => askScope(key, v),
|
onSelect: (v) => askScope(key, v),
|
||||||
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
|
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
|
||||||
@@ -1022,7 +1077,7 @@ export const COMMANDS: Command[] = [
|
|||||||
options: S.list().map((e) => ({
|
options: S.list().map((e) => ({
|
||||||
label: e.def.key,
|
label: e.def.key,
|
||||||
value: e.def.key,
|
value: e.def.key,
|
||||||
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}`,
|
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
|
||||||
})),
|
})),
|
||||||
onSelect: askValue,
|
onSelect: askValue,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ export interface Option {
|
|||||||
value: string
|
value: string
|
||||||
description?: string
|
description?: string
|
||||||
current?: boolean
|
current?: boolean
|
||||||
|
/** A two-handle range on this row (the Subagents setting, harness team.md §2: one range from 1
|
||||||
|
* to the cap, Min and Max). While the row is highlighted ←→ move the handle in focus and tab
|
||||||
|
* moves the focus; Min never passes Max. Enter calls `onSubmit` with both, not `onSelect`. */
|
||||||
|
range?: Range
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
/** The bounds of the track. */
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
/** The handles' values. */
|
||||||
|
lo: number
|
||||||
|
hi: number
|
||||||
|
/** The handles' names (Min, Max). */
|
||||||
|
names: [string, string]
|
||||||
|
onSubmit: (lo: number, hi: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The track as text: `─` outside the range, `━` inside it, `●` for a handle. */
|
||||||
|
export function rangeTrack(r: Pick<Range, "from" | "to">, lo: number, hi: number, width = 16): { before: string; lo: string; between: string; hi: string; after: string } {
|
||||||
|
const span = Math.max(1, r.to - r.from)
|
||||||
|
const at = (v: number) => Math.round(((v - r.from) / span) * (width - 1))
|
||||||
|
const a = at(lo)
|
||||||
|
const b = Math.max(at(hi), a + (hi > lo ? 1 : 0))
|
||||||
|
return { before: "─".repeat(a), lo: "●", between: "━".repeat(Math.max(0, b - a - 1)), hi: b > a ? "●" : "", after: "─".repeat(Math.max(0, width - 1 - b)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogSpec {
|
export interface DialogSpec {
|
||||||
@@ -45,6 +70,16 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
|
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
|
||||||
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
|
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
|
||||||
})
|
})
|
||||||
|
// A range row's handles, moved while the dialog is open and handed over on enter; esc drops them.
|
||||||
|
const [ranges, setRanges] = createSignal<Record<string, [number, number]>>({})
|
||||||
|
const [focus, setFocus] = createSignal<0 | 1>(1)
|
||||||
|
const valuesOf = (o: Option): [number, number] => ranges()[o.value] ?? [o.range!.lo, o.range!.hi]
|
||||||
|
const move = (o: Option, by: number) => {
|
||||||
|
const r = o.range!
|
||||||
|
const [lo, hi] = valuesOf(o)
|
||||||
|
const next: [number, number] = focus() === 0 ? [Math.min(hi, Math.max(r.from, lo + by)), hi] : [lo, Math.min(r.to, Math.max(lo, hi + by))]
|
||||||
|
setRanges((m) => ({ ...m, [o.value]: next }))
|
||||||
|
}
|
||||||
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
|
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
|
||||||
const [sel, setSel] = createSignal(start)
|
const [sel, setSel] = createSignal(start)
|
||||||
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
|
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
|
||||||
@@ -70,6 +105,8 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
run(o.value)
|
run(o.value)
|
||||||
}
|
}
|
||||||
} else if (key.name === "escape") p.close()
|
} else if (key.name === "escape") p.close()
|
||||||
|
else if (shown()[sel()]?.range && (key.name === "left" || key.name === "right")) move(shown()[sel()]!, key.name === "left" ? -1 : 1)
|
||||||
|
else if (shown()[sel()]?.range && key.name === "tab") setFocus((f) => (f === 0 ? 1 : 0))
|
||||||
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
|
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
|
||||||
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
|
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
|
||||||
else if (key.name === "return") {
|
else if (key.name === "return") {
|
||||||
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
const onSelect = p.spec.onSelect
|
const onSelect = p.spec.onSelect
|
||||||
const custom = p.spec.custom
|
const custom = p.spec.custom
|
||||||
const typed = query().trim()
|
const typed = query().trim()
|
||||||
|
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
|
||||||
p.close()
|
p.close()
|
||||||
if (o.value === CUSTOM) custom?.onSubmit(typed)
|
if (o.value === CUSTOM) custom?.onSubmit(typed)
|
||||||
|
else if (range) range.submit(...range.values)
|
||||||
else onSelect(o.value)
|
else onSelect(o.value)
|
||||||
}
|
}
|
||||||
} else if (filterable() && key.name === "backspace") {
|
} else if (filterable() && key.name === "backspace") {
|
||||||
@@ -104,6 +143,30 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
<For each={shown().slice(offset(), offset() + visible())}>
|
<For each={shown().slice(offset(), offset() + visible())}>
|
||||||
{(o, i) => {
|
{(o, i) => {
|
||||||
const active = () => i() + offset() === sel()
|
const active = () => i() + offset() === sel()
|
||||||
|
if (o.range) {
|
||||||
|
const values = () => valuesOf(o)
|
||||||
|
const track = () => rangeTrack(o.range!, values()[0], values()[1])
|
||||||
|
const handle = (n: 0 | 1) => (active() && focus() === n ? c().accent : active() ? c().title : c().text)
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
||||||
|
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
||||||
|
{" "}
|
||||||
|
{o.label}
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: handle(0) }}>{`${o.range.names[0]} ${values()[0]}`}</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: c().dim }}>{track().before}</span>
|
||||||
|
<span style={{ fg: handle(0) }}>{track().lo}</span>
|
||||||
|
<span style={{ fg: c().accent }}>{track().between}</span>
|
||||||
|
<span style={{ fg: handle(1) }}>{track().hi}</span>
|
||||||
|
<span style={{ fg: c().dim }}>{track().after}</span>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: handle(1) }}>{`${o.range.names[1]} ${values()[1]}`}</span>
|
||||||
|
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
|
||||||
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
|
||||||
@@ -118,7 +181,11 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
|
|||||||
<Show when={shown().length === 0}>
|
<Show when={shown().length === 0}>
|
||||||
<text fg={c().muted}>nothing matches</text>
|
<text fg={c().muted}>nothing matches</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text>
|
<text fg={c().dim}>
|
||||||
|
{shown()[sel()]?.range
|
||||||
|
? `←→ move ${shown()[sel()]!.range!.names[focus()]} · tab ${shown()[sel()]!.range!.names[focus() === 0 ? 1 : 0]} · enter set · esc close`
|
||||||
|
: (p.spec.hint ?? "↑↓ move · enter choose · esc close")}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { createSignal, For, onCleanup } from "solid-js"
|
import { createSignal, For, onCleanup } from "solid-js"
|
||||||
import { duration } from "../../duration.ts"
|
import { duration } from "../../duration.ts"
|
||||||
|
import { progressLine } from "../../session/progress.ts"
|
||||||
import { useTui } from "../context.ts"
|
import { useTui } from "../context.ts"
|
||||||
import { fmtTokens } from "./status.tsx"
|
import { fmtTokens } from "./status.tsx"
|
||||||
|
|
||||||
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
|
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
|
||||||
* return_progress). The time left is guessed from the reading so far, once there is enough of it;
|
* return_progress). The time left is guessed from the reading so far, once there is enough of it;
|
||||||
* a long prompt reads slower towards its end, so it is a floor more than a promise. */
|
* a long prompt reads slower towards its end, so it is a floor more than a promise. */
|
||||||
|
/** How long the rest of the prompt will take, from the server's pace so far; "" until it is known. */
|
||||||
|
export function etaOf(p: { total: number; done: number; processed: number; ms: number }): string {
|
||||||
|
if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) return `~${duration(((p.total - p.done) / p.processed) * p.ms, true)} left`
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
|
export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
|
||||||
const pct = Math.floor((p.done / p.total) * 100)
|
const pct = Math.floor((p.done / p.total) * 100)
|
||||||
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
||||||
@@ -34,6 +41,16 @@ export function Spinner() {
|
|||||||
}
|
}
|
||||||
const what = () => {
|
const what = () => {
|
||||||
const s = view.state
|
const s = view.state
|
||||||
|
// Spec 3.4.0: the phase the engine says, drawn as every client draws it (progress.md §3).
|
||||||
|
if (s.now) {
|
||||||
|
tick()
|
||||||
|
// The CLI's own look around the shared line: how long the rest of the prompt will take.
|
||||||
|
// The server's counts as they come (the phase event is paced for the link; the screen is not).
|
||||||
|
const reading = s.now.phase === "reading_prompt" && s.progress
|
||||||
|
const now = reading ? { ...s.now, done: s.progress!.done, total: s.progress!.total } : s.now
|
||||||
|
const eta = reading ? etaOf(s.progress!) : ""
|
||||||
|
return `${progressLine(now)}${eta ? ` · ${eta}` : ""}`
|
||||||
|
}
|
||||||
if (s.preparing) return `preparing ${s.preparing}`
|
if (s.preparing) return `preparing ${s.preparing}`
|
||||||
if (s.phase === "waiting" && s.progress) return reading(s.progress)
|
if (s.phase === "waiting" && s.progress) return reading(s.progress)
|
||||||
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
||||||
@@ -45,9 +62,9 @@ export function Spinner() {
|
|||||||
<box paddingLeft={1} height={1}>
|
<box paddingLeft={1} height={1}>
|
||||||
<text wrapMode="none" truncate>
|
<text wrapMode="none" truncate>
|
||||||
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
|
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
|
||||||
<span style={{ fg: theme().colors.muted }}>{what()}… </span>
|
<span style={{ fg: theme().colors.muted }}>{what()}{view.state.now ? " · " : "… "}</span>
|
||||||
<span style={{ fg: theme().colors.dim }}>
|
<span style={{ fg: theme().colors.dim }}>
|
||||||
{elapsed()} · {tokens()} · esc to stop
|
{view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
|
|||||||
const j = job()
|
const j = job()
|
||||||
if (!j) return `subagent · ${p.id} · gone · esc back`
|
if (!j) return `subagent · ${p.id} · gone · esc back`
|
||||||
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
|
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
|
||||||
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
|
const message = j.team ? `read-only: ${j.team} works for the main agent` : j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
|
||||||
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
|
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
|
||||||
}
|
}
|
||||||
const what = () => job()?.description ?? ""
|
const what = () => job()?.description ?? ""
|
||||||
|
|||||||
+6
-1
@@ -82,7 +82,7 @@ export interface Pending {
|
|||||||
/** What the model says the call is for. */
|
/** What the model says the call is for. */
|
||||||
purpose?: string
|
purpose?: string
|
||||||
/** The subagent (running as a job) that asks, when one does. */
|
/** The subagent (running as a job) that asks, when one does. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
resolve: (r: AskReply) => void
|
resolve: (r: AskReply) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ export interface ViewState {
|
|||||||
progress?: { total: number; done: number; processed: number; ms: number }
|
progress?: { total: number; done: number; processed: number; ms: number }
|
||||||
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
||||||
jobs: TrackedJob[]
|
jobs: TrackedJob[]
|
||||||
|
/** Spec 3.4.0 (harness/progress.md): what the agent is doing now — the spinner line. */
|
||||||
|
now?: import("../session/progress.ts").Progress | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the model was given beside the words typed — attachments, and a session note
|
// What the model was given beside the words typed — attachments, and a session note
|
||||||
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
|
|||||||
if (e.state === "start") startTask()
|
if (e.state === "start") startTask()
|
||||||
else inTask = false
|
else inTask = false
|
||||||
break
|
break
|
||||||
|
case "phase":
|
||||||
|
set("now", e.progress ? { ...e.progress } : null)
|
||||||
|
break
|
||||||
case "sub_tool":
|
case "sub_tool":
|
||||||
set(
|
set(
|
||||||
"items",
|
"items",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
|
|||||||
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
||||||
config(fake.url, true)
|
config(fake.url, true)
|
||||||
const { c, events, sid } = await client()
|
const { c, events, sid } = await client()
|
||||||
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
|
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go (a subagent on g/m)" }] })
|
||||||
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
||||||
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
||||||
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
|
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
|
||||||
|
|||||||
+1
-1
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
|
|||||||
config(fake.url)
|
config(fake.url)
|
||||||
const root = project()
|
const root = project()
|
||||||
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
|
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
|
||||||
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show"])
|
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show", "progress", "tree", "agents"])
|
||||||
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
|
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
|
||||||
|
|
||||||
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
||||||
|
|||||||
+30
-3
@@ -51,10 +51,37 @@ test("task on a model the server cannot hold beside the session's is refused, an
|
|||||||
)
|
)
|
||||||
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||||
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||||
await a.engine.prompt("get a second opinion")
|
await a.engine.prompt("get a second opinion from f/other")
|
||||||
// Two requests: the session's, then its next step — never one for f/other.
|
// Two requests: the session's, then its next step — never one for f/other.
|
||||||
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||||
const result = fake.requests[1].messages.at(-1).content as string
|
const result = fake.requests[1].messages.at(-1).content as string
|
||||||
expect(result).toContain("holds one model at a time")
|
expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
|
||||||
expect(result).toContain("Do this part yourself")
|
})
|
||||||
|
|
||||||
|
test("spec 3.3.0: task on another model the person did not name is refused, and nothing runs", async () => {
|
||||||
|
fake = fakeProvider([
|
||||||
|
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "second opinion", prompt: "look at it", model: "f/other" }))] },
|
||||||
|
{ chunks: [delta({ content: "I will do it myself." })] },
|
||||||
|
])
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: {}, f/other: {} }\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||||
|
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||||
|
await a.engine.prompt("get a second opinion")
|
||||||
|
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||||
|
expect(fake.requests[1].messages.at(-1).content as string).toContain("Use another model for subagents only when the person names it.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("spec 3.3.0: a one-request session model is not its own subagent, and nothing else is picked", async () => {
|
||||||
|
fake = fakeProvider([
|
||||||
|
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "look", prompt: "look at it" }))] },
|
||||||
|
{ chunks: [delta({ content: "I will do it myself." })] },
|
||||||
|
])
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: { parallel: 1 }, f/other: {} }\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||||
|
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||||
|
await a.engine.prompt("look into it")
|
||||||
|
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||||
|
expect(fake.requests[1].messages.at(-1).content as string).toContain("This model serves one request at a time: name another model, on another server, for subagents.")
|
||||||
})
|
})
|
||||||
+101
-10
@@ -27,9 +27,13 @@ import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
|||||||
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
|
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
|
||||||
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||||
import { absPath, type ToolContext } from "../src/tool/tool.ts"
|
import { absPath, type ToolContext } from "../src/tool/tool.ts"
|
||||||
import { TOOL_SPECS } from "../src/harness.ts"
|
import { LOOP, TOOL_SPECS } from "../src/harness.ts"
|
||||||
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
import { LEAD_TOOLS, memberTools, Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||||
import { noteBlock } from "../src/session/prefix.ts"
|
import { noteBlock } from "../src/session/prefix.ts"
|
||||||
|
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
|
||||||
|
import { progressLine, progressText, type Progress } from "../src/session/progress.ts"
|
||||||
|
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
|
||||||
|
import { fillPrompt as fillText } from "../src/prompt/fill.ts"
|
||||||
|
|
||||||
type Case = Record<string, unknown>
|
type Case = Record<string, unknown>
|
||||||
|
|
||||||
@@ -233,12 +237,35 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
|
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
|
||||||
throw new Error(`paths: unknown op ${String(c.op)}`)
|
throw new Error(`paths: unknown op ${String(c.op)}`)
|
||||||
},
|
},
|
||||||
|
// Subagents (subagents.md, spec 3.3.0): the model a subagent runs on, through the rules spawn and
|
||||||
|
// team_add ask; the offer through the Team the app makes (its tools, setting and note).
|
||||||
|
subagents: (c) => {
|
||||||
|
const models = Object.entries(c.models as Record<string, Omit<ModelInfo, "ref">>).map(([ref, m]) => ({ ref, ...m }))
|
||||||
|
const byRef = (ref: string) => models.find((m) => m.ref === ref)!
|
||||||
|
const session = byRef(c.session as string)
|
||||||
|
if (c.op === "model") {
|
||||||
|
const r = subagentModel(session, c.model === undefined ? undefined : byRef(c.model as string), (c.said as string[] | undefined) ?? [])
|
||||||
|
return "refused" in r ? { refused: r.refused } : { model: r.model }
|
||||||
|
}
|
||||||
|
if (c.op === "offer") {
|
||||||
|
const subagents = c.subagents !== false
|
||||||
|
const pure = offer(session, models, subagents)
|
||||||
|
const team = new Team({ min: 1, max: 4, subagents, model: session.ref, capacity: (ref) => byRef(ref).capacity, models: () => models.map((m) => m.ref) })
|
||||||
|
if (team.offered !== pure.offered) throw new Error("subagents: the Team and the rule disagree on the offer")
|
||||||
|
const noted = team.offered && team.noteLines()[0]!.split("\n").includes(LOOP.context.note.team_own)
|
||||||
|
return { offered: team.offered, own: pure.own, note: noted ? LOOP.context.note.team_own : null }
|
||||||
|
}
|
||||||
|
throw new Error(`subagents: unknown op ${String(c.op)}`)
|
||||||
|
},
|
||||||
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
|
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
|
||||||
// (team/team.ts); the app drives the same class from real turns.
|
// (team/team.ts); the app drives the same class from real turns.
|
||||||
team: (c) => {
|
team: (c) => {
|
||||||
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity }
|
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity; mode?: Mode }
|
||||||
let capacity = start.capacity
|
let capacity = start.capacity
|
||||||
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity })
|
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity, mode: start.mode ?? "edit", steps: { main: 200, sub: 50 } })
|
||||||
|
// What each agent's turn was started with (a brief, or the messages that woke it).
|
||||||
|
const started: Record<string, string> = {}
|
||||||
|
team.effects = { start: (n, t) => void (started[n] = t), deliver: () => {}, stop: () => {} }
|
||||||
const delivered = () => {
|
const delivered = () => {
|
||||||
const out: Record<string, string[]> = {}
|
const out: Record<string, string[]> = {}
|
||||||
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
||||||
@@ -249,8 +276,8 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
switch (st.op) {
|
switch (st.op) {
|
||||||
case "offered": {
|
case "offered": {
|
||||||
const a = team.get(by)!
|
const a = team.get(by)!
|
||||||
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
|
const tools = !team.offered ? [] : by === "lead" ? [...LEAD_TOOLS] : memberTools(a)
|
||||||
return { tools: tools.sort(), setting: team.offered }
|
return { tools: [...tools].sort(), setting: team.offered }
|
||||||
}
|
}
|
||||||
case "call": {
|
case "call": {
|
||||||
const args = st.args as Record<string, any>
|
const args = st.args as Record<string, any>
|
||||||
@@ -260,20 +287,29 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
|
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
|
||||||
return { room, delivered: delivered() }
|
return { room, delivered: delivered() }
|
||||||
}
|
}
|
||||||
if (st.tool === "team_add") result = team.add(by, args as any).text
|
const first = (TEAM_TOOLS as readonly string[]).includes(st.tool) ? team.refusal(by, st.tool, args) : undefined
|
||||||
|
if (first) result = first
|
||||||
|
else if (st.tool === "team_add") result = team.add(by, args as any).text
|
||||||
else if (st.tool === "team_send") result = team.send(by, args as any)
|
else if (st.tool === "team_send") result = team.send(by, args as any)
|
||||||
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||||
|
else if (st.tool === "team_assign") result = team.assign(by, args as any)
|
||||||
|
else if (st.tool === "team_replace") result = team.replace(by, args as any).text
|
||||||
|
else if (st.tool === "team_finish") result = team.finishTask(by, args.report)
|
||||||
|
else if (st.tool === "team_whoami") result = team.whoami(by)
|
||||||
|
else if (st.tool === "team_findings") result = args.action === "write" ? team.noteFinding(by, args.text ?? "") : team.readFindings(by, args.of)
|
||||||
|
else if (st.tool === "team_request") result = team.request(by, args as any, TOOL_SPECS[args.tool]?.risk as any)
|
||||||
|
else if (st.tool === "team_decide") result = team.decide(by, args as any)
|
||||||
else {
|
else {
|
||||||
result = team.refusal(by, st.tool, args) ?? null
|
result = team.refusal(by, st.tool, args) ?? null
|
||||||
if (st.tool === "task" && result === null && team.offered) team.helperStart(by)
|
if (st.tool === "task" && result === null && team.offered) team.taskStart(by)
|
||||||
}
|
}
|
||||||
return { result, delivered: delivered() }
|
return { result, delivered: delivered() }
|
||||||
}
|
}
|
||||||
case "finish":
|
case "finish":
|
||||||
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
||||||
return { delivered: delivered() }
|
return { delivered: delivered() }
|
||||||
case "helper_done":
|
case "task_done":
|
||||||
team.helperDoneBy(by)
|
team.taskDoneBy(by)
|
||||||
return null
|
return null
|
||||||
case "set":
|
case "set":
|
||||||
if (st.capacity) capacity = st.capacity
|
if (st.capacity) capacity = st.capacity
|
||||||
@@ -289,10 +325,65 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
}
|
}
|
||||||
case "state":
|
case "state":
|
||||||
return team.state()
|
return team.state()
|
||||||
|
// Spec 3.4.0.
|
||||||
|
case "context":
|
||||||
|
team.setContext(by, st.used, st.window)
|
||||||
|
return null
|
||||||
|
case "context_full":
|
||||||
|
team.pause(by)
|
||||||
|
return { delivered: delivered() }
|
||||||
|
case "started":
|
||||||
|
return { text: started[by] ?? null }
|
||||||
|
case "tree": {
|
||||||
|
const t = team.info()
|
||||||
|
const row = (a: (typeof t.agents)[number]) => ({ id: a.id, name: a.name, parent: a.parent, state: a.state, mode: a.mode, request: a.request })
|
||||||
|
return { size: t.size, open_slots: t.open_slots, agents: t.agents.map(row), ended: t.ended.map((a) => ({ id: a.id, name: a.name, state: a.state })) }
|
||||||
|
}
|
||||||
|
case "main_mode":
|
||||||
|
team.setMainMode(st.mode)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
throw new Error(`team: unknown op ${String(st.op)}`)
|
throw new Error(`team: unknown op ${String(st.op)}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
// Progress (progress.md, spec 3.4.0): the text and the line a client draws.
|
||||||
|
progress: (c) => {
|
||||||
|
const p = c.progress as Progress
|
||||||
|
return { text: progressText(p, c.now as number), line: progressLine(p, c.now as number) }
|
||||||
|
},
|
||||||
|
// Delegation (subagents.md §7, spec 3.4.0): the mode check, who decides a call, team_request's
|
||||||
|
// check, and tightening down the tree.
|
||||||
|
delegation: (c) => {
|
||||||
|
if (c.op === "add") {
|
||||||
|
const r = checkMode(c.parent_mode as Mode, c.mode as Mode | undefined, c.request as string[] | undefined)
|
||||||
|
return "error" in r ? { result: r.error, mode: null } : { result: null, mode: r.mode }
|
||||||
|
}
|
||||||
|
if (c.op === "call") {
|
||||||
|
const call = c.call as { tool: string; line?: string; path?: string }
|
||||||
|
const req: PermissionRequest =
|
||||||
|
call.tool === "bash"
|
||||||
|
? { permission: "bash", class: "execute", patterns: [call.line!], command: call.line!, paths: ["/project"] }
|
||||||
|
: { permission: call.tool === "read" ? "read" : "edit", class: call.tool === "read" ? "read" : "write", patterns: [call.path!], paths: [`/project/${call.path}`] }
|
||||||
|
const v = whoDecides(req, {
|
||||||
|
ctx: { rules: toRules(DEFAULT_RULES, "default"), hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" },
|
||||||
|
mode: c.mode as Mode,
|
||||||
|
main: c.main_mode as Mode,
|
||||||
|
request: (c.request as string[] | undefined) ?? [],
|
||||||
|
grants: (c.grants as Grant[] | undefined) ?? [],
|
||||||
|
unattended: c.unattended === true,
|
||||||
|
})
|
||||||
|
// The text is compared unless the case says the reason is each project's own.
|
||||||
|
return v.answer === "refused" && c.own_reason !== true ? { answer: v.answer, text: v.text } : { answer: v.answer }
|
||||||
|
}
|
||||||
|
if (c.op === "request") {
|
||||||
|
const cls = TOOL_SPECS[c.tool as string]?.risk as "read" | "write" | "execute" | undefined
|
||||||
|
return { result: requestRefusal(c.tool as string, cls, c.request as string[]) ?? fillText(LOOP.team.texts.requested, { id: `${LOOP.team.request.id_prefix}1` }) }
|
||||||
|
}
|
||||||
|
if (c.op === "switch") {
|
||||||
|
return tighten(c.main_to as Mode, c.agents as { name: string; parent: string; mode: Mode }[])
|
||||||
|
}
|
||||||
|
throw new Error(`delegation: unknown op ${String(c.op)}`)
|
||||||
|
},
|
||||||
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||||
toolcall_index: (c) => {
|
toolcall_index: (c) => {
|
||||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ describe("context", () => {
|
|||||||
await a.engine.prompt("read it")
|
await a.engine.prompt("read it")
|
||||||
const system = a.engine.compose(a.engine.mode)
|
const system = a.engine.compose(a.engine.mode)
|
||||||
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
|
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
|
||||||
expect(text).toContain("window 32.8k")
|
expect(text).toContain("/ 32.8k tokens")
|
||||||
|
expect(text).toMatch(/until auto-compact \(at 85%\) — \/compact to compact now/)
|
||||||
expect(text).toContain("system prompt")
|
expect(text).toContain("system prompt")
|
||||||
expect(text).toMatch(/tool definitions \(\d+\)/)
|
expect(text).toMatch(/tool definitions \(\d+\)/)
|
||||||
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
|
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ describe("end to end", () => {
|
|||||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
||||||
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
||||||
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
||||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "team_whoami", "team_findings", "team_decide", "team_assign", "team_replace", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
||||||
const ends = events.filter((e) => e.type === "tool_end")
|
const ends = events.filter((e) => e.type === "tool_end")
|
||||||
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
||||||
const sid = store.sessions(1)[0]!.id
|
const sid = store.sessions(1)[0]!.id
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's
|
|||||||
// plan_write's former name rather than a tool.
|
// plan_write's former name rather than a tool.
|
||||||
expect(TOOL_SPECS.todo).toBeUndefined()
|
expect(TOOL_SPECS.todo).toBeUndefined()
|
||||||
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
|
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
|
||||||
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_read", "team_send", "team_stop", "web_fetch", "web_search", "write"]
|
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami", "web_fetch", "web_search", "write"]
|
||||||
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
|
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -555,3 +555,17 @@ test("lembas login --token-stdin --server --json: the token from stdin, the devi
|
|||||||
expect((await cli(["login", "--token-stdin", fake.url], "lmb_secret")).code).toBe(2)
|
expect((await cli(["login", "--token-stdin", fake.url], "lmb_secret")).code).toBe(2)
|
||||||
expect((await cli(["login", "--server", fake.url], "lmb_secret")).code).toBe(2)
|
expect((await cli(["login", "--server", fake.url], "lmb_secret")).code).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("lembas login over a sign-in the instance no longer accepts signs in afresh, not in a loop", async () => {
|
||||||
|
fake = lembas()
|
||||||
|
await login(fake.url, { ...quick, onCode: () => {} })
|
||||||
|
// The instance forgot the token kept here (revoked, or another instance's database).
|
||||||
|
fake.token = "lmb_fresh"
|
||||||
|
fake.polls = 0
|
||||||
|
const r = await cli(["login", fake.url, "--json"], "")
|
||||||
|
expect(r.code).toBe(0)
|
||||||
|
const lines = r.out.trim().split("\n").map((l) => JSON.parse(l))
|
||||||
|
expect(lines[0]).toMatchObject({ event: "code" })
|
||||||
|
expect(lines.at(-1)).toMatchObject({ event: "done", fresh: true, connection: "example" })
|
||||||
|
expect(readFileSync(keyFile("example"), "utf8").trim()).toBe("lmb_fresh")
|
||||||
|
})
|
||||||
@@ -197,7 +197,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
],
|
],
|
||||||
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
|
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
|
||||||
)
|
)
|
||||||
await a.turns.prompt("look into it in the background")
|
await a.turns.prompt("look into it in the background (subagents on g/m or g/n)")
|
||||||
const started = fake!.requests[1].messages.at(-1).content as string
|
const started = fake!.requests[1].messages.at(-1).content as string
|
||||||
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
||||||
await settled(a, 3)
|
await settled(a, 3)
|
||||||
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
{ chunks: [delta({ content: "Second report." })] },
|
{ chunks: [delta({ content: "Second report." })] },
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
await a.turns.prompt("go")
|
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||||
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
|
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
|
||||||
await settled(a, 6)
|
await settled(a, 6)
|
||||||
const child = other!.requests
|
const child = other!.requests
|
||||||
@@ -252,7 +252,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
|
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
|
||||||
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
|
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
|
||||||
)
|
)
|
||||||
await a.turns.prompt("go")
|
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||||
const msgs = fake!.requests[1].messages
|
const msgs = fake!.requests[1].messages
|
||||||
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
|
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
|
||||||
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
|
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
|
||||||
@@ -278,7 +278,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
|
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
|
||||||
{ asker },
|
{ asker },
|
||||||
)
|
)
|
||||||
await a.turns.prompt("go")
|
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||||
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
|
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
|
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
|
||||||
{ oneAtATime: true },
|
{ oneAtATime: true },
|
||||||
)
|
)
|
||||||
await a.turns.prompt("go")
|
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||||
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||||
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
|
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
|
||||||
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
|
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
|
||||||
@@ -330,7 +330,7 @@ test("a cancelled worktree subagent keeps its branch, and the result names it",
|
|||||||
const root = a.project.root
|
const root = a.project.root
|
||||||
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
|
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
|
||||||
;(a.project as any).gitRoot = root
|
;(a.project as any).gitRoot = root
|
||||||
const turn = a.turns.prompt("fix it in a worktree")
|
const turn = a.turns.prompt("fix it in a worktree (subagents on g/m or g/n)")
|
||||||
const until = Date.now() + 8000
|
const until = Date.now() + 8000
|
||||||
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
|
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
|
||||||
const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
|
const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Spec 3.3.0 (harness/subagents.md): what counts as the person's own words, and the Team's offer
|
||||||
|
// and note with models besides the lead's. The rules themselves: conformance area `subagents`.
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { LOOP } from "../src/harness.ts"
|
||||||
|
import { STEER_MARK } from "../src/session/engine.ts"
|
||||||
|
import { named, personTexts } from "../src/team/subagents.ts"
|
||||||
|
import { Team, type Capacity } from "../src/team/team.ts"
|
||||||
|
import type { Message } from "../src/provider/types.ts"
|
||||||
|
|
||||||
|
test("the person's words: prompts and messages sent mid-turn; never notes, marked messages, machine turns or the model's text", () => {
|
||||||
|
const msgs: Message[] = [
|
||||||
|
{ role: "user", parts: [{ type: "text", text: "Fix the parser." }, { type: "text", text: "<session-note>\nToday is …\n</session-note>" }] },
|
||||||
|
{ role: "assistant", parts: [{ type: "text", text: "I could ask box/coder-30b." }] },
|
||||||
|
{ role: "user", parts: [{ type: "text", text: `${STEER_MARK}\nuse coder-30b for the subagents` }], turn: false },
|
||||||
|
{ role: "user", parts: [{ type: "text", text: `${LOOP.jobs.marks.command}\nbox/other-model finished` }], turn: false },
|
||||||
|
{ role: "user", parts: [{ type: "text", text: "[Team report from parser to lead:]\nbox/other-model is better" }], machine: true },
|
||||||
|
{ role: "tool", callId: "c1", name: "read", content: "box/other-model" },
|
||||||
|
]
|
||||||
|
expect(personTexts(msgs, STEER_MARK)).toEqual(["Fix the parser.", "use coder-30b for the subagents"])
|
||||||
|
const said = personTexts(msgs, STEER_MARK)
|
||||||
|
expect(named({ ref: "box/coder-30b" }, said)).toBe(true)
|
||||||
|
expect(named({ ref: "box/other-model" }, said)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a one-request lead with a model on another server: offered, and the size note says it cannot serve its own", () => {
|
||||||
|
const caps: Record<string, Capacity> = { "lab/qwen": { group: "lab", parallel: 1 }, "lab/gemma": { group: "lab", parallel: 1 }, "box/coder": { parallel: 1 } }
|
||||||
|
const only = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => ["lab/qwen", "lab/gemma"] })
|
||||||
|
expect(only.offered).toBe(false)
|
||||||
|
const t = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => Object.keys(caps) })
|
||||||
|
expect(t.offered).toBe(true)
|
||||||
|
expect(t.unavailable()).toBeUndefined()
|
||||||
|
expect(t.noteLines()).toEqual([`The team may have 1 to 4 agents, you included.\n${LOOP.context.note.team_own}`])
|
||||||
|
})
|
||||||
+6
-6
@@ -26,7 +26,7 @@ const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
|||||||
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
||||||
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
|
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
|
||||||
const system = (body: any): string => body.messages[0].content
|
const system = (body: any): string => body.messages[0].content
|
||||||
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
|
const isMember = (body: any, name: string) => new RegExp(`You are "${name}" \\(s\\d+\\), a subagent`).test(system(body))
|
||||||
const lastUser = (body: any): string => {
|
const lastUser = (body: any): string => {
|
||||||
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
||||||
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
||||||
@@ -94,8 +94,8 @@ describe("a team at work (team.md)", () => {
|
|||||||
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
|
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
|
||||||
expect(t.get("parser")!.state).toBe("finished")
|
expect(t.get("parser")!.state).toBe("finished")
|
||||||
expect(t.isManager("lead")).toBe(true)
|
expect(t.isManager("lead")).toBe(true)
|
||||||
expect(teamLabel(t)).toBe("team 2/4")
|
expect(teamLabel(t)).toBe("agents 2/4")
|
||||||
expect(teamText(app, "")).toContain("- parser — the parser · finished")
|
expect(teamText(app, "")).toContain("- parser (s1) — the parser · finished")
|
||||||
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||||
// A member is not a job to the model.
|
// A member is not a job to the model.
|
||||||
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
||||||
@@ -135,10 +135,10 @@ describe("a team at work (team.md)", () => {
|
|||||||
const dir = setup(() => say(`ok ${n++}`))
|
const dir = setup(() => say(`ok ${n++}`))
|
||||||
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
await app.engine.prompt("one")
|
await app.engine.prompt("one")
|
||||||
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
|
expect(teamText(app, "size 3 2")).toContain("1 ≤ Min ≤ Max ≤ 32")
|
||||||
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
|
expect(teamText(app, "size 1 40")).toContain("1 ≤ Min ≤ Max ≤ 32")
|
||||||
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
|
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
|
||||||
expect(teamText(app, "size 2 6")).toContain("2 to 6")
|
expect(teamText(app, "size 2 6")).toContain("Min 2, Max 6")
|
||||||
expect(app.settings.get("agents.max").value).toBe(6)
|
expect(app.settings.get("agents.max").value).toBe(6)
|
||||||
await app.engine.prompt("two")
|
await app.engine.prompt("two")
|
||||||
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
|
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
|
||||||
|
|||||||
@@ -372,14 +372,14 @@ test("thinking is one line — its text is not shown; the provider's count of it
|
|||||||
expect(await t.until("SECRET-INNER-STEP")).toBe(true)
|
expect(await t.until("SECRET-INNER-STEP")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("while it thinks: the thought says only Thinking…; the line at the bottom has the verb, the task's time and tokens", async () => {
|
test("while it thinks: the thought says only Thinking…; the line at the bottom has the phase (progress.md), the task's time and tokens", async () => {
|
||||||
t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }])
|
t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }])
|
||||||
await t.input.typeText("go")
|
await t.input.typeText("go")
|
||||||
t.input.pressEnter()
|
t.input.pressEnter()
|
||||||
expect(await t.until("~15 tokens")).toBe(true)
|
expect(await t.until("~15 tokens")).toBe(true)
|
||||||
const frame = t.frame()
|
const frame = t.frame()
|
||||||
expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m)
|
expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m)
|
||||||
expect(frame).toMatch(/(pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|reasoning|analyzing|synthesizing|formulating)… \d+s · ~15 tokens · esc to stop/)
|
expect(frame).toMatch(/Thinking · \d+s · turn \d+s · ~15 tokens · esc to stop/)
|
||||||
expect(frame).not.toContain("HIDDEN-WHILE-THINKING")
|
expect(frame).not.toContain("HIDDEN-WHILE-THINKING")
|
||||||
expect(await t.until("Done thinking.")).toBe(true)
|
expect(await t.until("Done thinking.")).toBe(true)
|
||||||
expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/)
|
expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/)
|
||||||
@@ -397,7 +397,7 @@ test("the line at the bottom counts the whole task: the clock and the tokens go
|
|||||||
// The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the
|
// The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the
|
||||||
// frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this
|
// frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this
|
||||||
// state, so wait for the line within it rather than reading the first frame.
|
// state, so wait for the line within it rather than reading the first frame.
|
||||||
const counted = /writing… \d+s · ~4\d tokens · esc to stop/
|
const counted = /Writing(: [\d.]+ tok\/s)? · \d+s · turn \d+s · ~4\d tokens · esc to stop/
|
||||||
for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle()
|
for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle()
|
||||||
expect(t.frame()).toMatch(counted)
|
expect(t.frame()).toMatch(counted)
|
||||||
expect(await t.until("way.")).toBe(true)
|
expect(await t.until("way.")).toBe(true)
|
||||||
@@ -922,10 +922,12 @@ test("while the server reads a long prompt: the line at the bottom says how far
|
|||||||
t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }])
|
t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }])
|
||||||
await t.input.typeText("go")
|
await t.input.typeText("go")
|
||||||
t.input.pressEnter()
|
t.input.pressEnter()
|
||||||
expect(await t.until("reading the prompt 64% · 90k of 140k · ~1m 40s left")).toBe(true)
|
// At 100 columns the line is cut in its middle; its start and its end are what is checked.
|
||||||
|
expect(await t.until("~1m 40s left")).toBe(true)
|
||||||
|
expect(t.frame()).toContain("Reading the prompt: 90000 / 140000 tokens (64%")
|
||||||
expect(await t.until("Read it all.")).toBe(true)
|
expect(await t.until("Read it all.")).toBe(true)
|
||||||
await t.settle()
|
await t.settle()
|
||||||
expect(t.frame()).not.toContain("reading the prompt")
|
expect(t.frame()).not.toContain("Reading the prompt")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {
|
test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {
|
||||||
|
|||||||
@@ -36,3 +36,50 @@ test("dialog: arrow down + enter picks the second option — with a spec that cl
|
|||||||
expect(picked).toEqual(["b"])
|
expect(picked).toEqual(["b"])
|
||||||
expect(closed).toBe(1)
|
expect(closed).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("dialog: a range row is one track with two handles — ←→ move the one in focus, tab the other, enter hands both over", async () => {
|
||||||
|
const set: [number, number][] = []
|
||||||
|
const picked: string[] = []
|
||||||
|
const ctx = { theme: () => DEFAULT_THEME, dims: () => ({ width: 72, height: 16 }) } as never
|
||||||
|
const [spec, setSpec] = createSignal<DialogSpec | undefined>({
|
||||||
|
title: "Effort",
|
||||||
|
options: [
|
||||||
|
{ label: "high", value: "high", current: true },
|
||||||
|
{ label: "Subagents", value: "team", description: "1 is this agent alone", range: { from: 1, to: 32, lo: 1, hi: 4, names: ["Min", "Max"], onSubmit: (lo, hi) => set.push([lo, hi]) } },
|
||||||
|
],
|
||||||
|
onSelect: (v) => picked.push(v),
|
||||||
|
})
|
||||||
|
setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<TuiContext.Provider value={ctx}>
|
||||||
|
<Show when={spec()}>
|
||||||
|
<Dialog spec={spec()!} close={() => setSpec(undefined)} />
|
||||||
|
</Show>
|
||||||
|
</TuiContext.Provider>
|
||||||
|
),
|
||||||
|
{ width: 72, height: 16 },
|
||||||
|
)
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Subagents Min 1 ●")
|
||||||
|
setup.mockInput.pressArrow("down")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("←→ move Max · tab Min")
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Max 6")
|
||||||
|
setup.mockInput.pressTab()
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
setup.mockInput.pressArrow("right")
|
||||||
|
setup.mockInput.pressArrow("left")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Min 2")
|
||||||
|
// Min never passes Max, and Min never goes under 1.
|
||||||
|
for (let i = 0; i < 10; i++) setup.mockInput.pressArrow("right")
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).toContain("Min 6")
|
||||||
|
setup.mockInput.pressEnter()
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(set).toEqual([[6, 6]])
|
||||||
|
expect(picked).toEqual([])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user