8 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 6a0e0ed145 Subagents: Min and Max, one range with two handles, counting this agent (harness spec 3.3.0)
ci / check (push) Waiting to run
/effort's Subagents row and /team size are a two-handle range from 1 to the cap (left/right move
the handle in focus, tab the other, enter sets both); the dialog gains a range row. Every text says
Min and Max, never fewest/most, and that the count includes this agent: "Subagents: Min 1, Max 4 -
1 is this agent alone". /settings shows the same beside agents.min / agents.max; config schema
and the bounds refusal likewise. CHANGELOG in the 2.0.0-beta.5 section.

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

Still 3.3.0, unreleased: edited in place.

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

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

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

No files matched your search

+47
View File
@@ -4,6 +4,53 @@ 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/).
## [Unreleased]
## [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
+34 -4
View File
@@ -22,10 +22,11 @@ page *Harness-parity* in LLeMbas CLI's wiki.
| `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; `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 |
| `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 +34,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 +49,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 +70,34 @@ 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.3.0
Written ahead of both implementations as LLeMbas CLI 02c7282; both carry it in the same round (`subagents.md`).
- **Subagent** is the word everywhere a person or a model reads one — a `task` subagent and a
team's member alike; *helper* is retired. Tool names and aliases are unchanged. The `team`
conformance area's step `helper_done` is now **`task_done`**.
- **The model**: a subagent runs on the session's model, unless the person named another one in
this session's own messages (its served id, its id at its connection or its display name, a whole
word, without case — never the model's text or a tool's output). `task`'s and `team_add`'s
`model` is refused otherwise: "Use another model for subagents only when the person names it."
No setting chooses a subagent's model; an agent definition's own `model` is not used for one.
- **Capacity, the logical restrictions only**: another model on the session's model's one-model
server is refused ("That model is on the same one-model server as this chat's model; name a
model on another server."); the session's model as its own subagent needs `parallel ≥ 2` or no
limit, else "This model serves one request at a time: name another model, on another server,
for subagents." — and nothing else is picked. A member is refused for these too, and still only
waits for a full model.
- **The offer**: the `agents {min, max}` setting is shown as **Subagents** (**Min**, **Max**;
`loop.json` `subagents.setting`) — one range from 1 to the cap with two handles, and a hint that
the numbers count the session's own agent: 1 is the agent alone, Max 4 the agent and up to three
subagents — and with it the team tools, when subagents are enabled and at
least one model could serve: the session's, with the capacity, or any usable model off its
one-model server. `context.note.team_own` tells the model, and `subagents.setting.own` the
person, when the session's model cannot serve subagents itself.
- `task`'s and `team_add`'s `model` descriptions, `task`'s description (a line on the model),
`models/schema.json` capacity texts, `team.md` §2, §4, §8. Conformance area **`subagents`**.
## 3.2.0 ## 3.2.0
Written ahead of both implementations; both carry it in the same round. Written ahead of both implementations; both carry it in the same round.
+1 -1
View File
@@ -1 +1 @@
3.2.0 3.3.0
File diff suppressed because it is too large. Load diff
+57 -4
View File
@@ -1,5 +1,5 @@
{ {
"about": "Teams (team.md, spec 3.2.0): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model. Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a helper that counts toward the size (and holds a slot) until `helper_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — <text>`) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). LLeMbas: services/team.py; CLI: team/.", "about": "Teams (team.md, spec 3.2.0): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model, and it is the only model there is (spec 3.3.0: other models are subagents.json's). Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a subagent that counts toward the size (and holds a slot) until `task_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — <text>`) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). LLeMbas: services/team.py; CLI: team/.",
"cases": [ "cases": [
{ {
"about": "Offered on a model that serves two requests at once: the lead has all four tools and the setting is shown.", "about": "Offered on a model that serves two requests at once: the lead has all four tools and the setting is shown.",
@@ -52,7 +52,7 @@
] ]
}, },
{ {
"about": "A model that serves one request at a time offers no team: no tools, no setting.", "about": "A model that serves one request at a time, with no other model, offers no team: no tools, no setting.",
"start": { "start": {
"min": 1, "min": 1,
"max": 4, "max": 4,
@@ -727,7 +727,7 @@
] ]
}, },
{ {
"about": "A task helper counts toward max while it runs, inside a team.", "about": "A task subagent counts toward max while it runs, inside a team.",
"start": { "start": {
"min": 1, "min": 1,
"max": 3, "max": 3,
@@ -772,7 +772,7 @@
} }
}, },
{ {
"op": "helper_done", "op": "task_done",
"by": "lead" "by": "lead"
}, },
{ {
@@ -810,6 +810,59 @@
} }
] ]
}, },
{
"about": "The count includes the lead: max 2 is the lead and one subagent. A second task while the first runs is refused; once it is done there is room again.",
"start": {
"min": 1,
"max": 2,
"capacity": {}
},
"steps": [
{
"op": "call",
"by": "lead",
"tool": "task",
"args": {
"prompt": "Find the grammar."
}
},
{
"op": "call",
"by": "lead",
"tool": "task",
"args": {
"prompt": "x"
}
},
{
"op": "task_done",
"by": "lead"
},
{
"op": "call",
"by": "lead",
"tool": "task",
"args": {
"prompt": "x"
}
}
],
"expect": [
{
"result": null,
"delivered": {}
},
{
"result": "The team is full: it has as many agents as the person allows (2). Give the work to a member already on it (team_send), or stop one (team_stop) first.",
"delivered": {}
},
null,
{
"result": null,
"delivered": {}
}
]
},
{ {
"about": "Lowering max stops nobody: the members stay, and nothing is added until the size is under it. The lead keeps its team tools while it has members.", "about": "Lowering max stops nobody: the members stay, and nothing is added until the size is under it. The lead keeps its team tools while it has members.",
"start": { "start": {
+27 -4
View File
@@ -34,7 +34,7 @@
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.", "about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
"max": 4 "max": 4
}, },
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). A team's members are not refused there: they wait for a slot (team.md, Capacity).", "capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). Spec 3.3.0: which model a subagent may run on at all, and the refusals for the session's own one-model server and a one-request model, are subagents.md and `subagents` below, checked first. A team's members are not refused for a full model: they wait for a slot (team.md, Capacity).",
"jobs": { "jobs": {
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).", "about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
"marks": { "marks": {
@@ -61,8 +61,30 @@
}, },
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest." "lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
}, },
"subagents": {
"about": "Spec 3.3.0 (subagents.md): which model a subagent — a task subagent or a team member — runs on, and when subagents are offered. The session's own model unless the person named another in this session; no setting chooses one. The texts are said word for word, checked in this order: not_named, same_group, one_request. conformance/subagents.json.",
"texts": {
"not_named": "Use another model for subagents only when the person names it.",
"same_group": "That model is on the same one-model server as this chat's model; name a model on another server.",
"one_request": "This model serves one request at a time: name another model, on another server, for subagents."
},
"named": {
"about": "A model is named when its served id, its id at its connection or its display name occurs as a whole word, without case, in a message the person typed in this session (never model text, tool output, files, notes, marked messages or reports; a compacted message still counts). Before the name: not a letter, digit, _, -, /, . or :. After it: not a letter, digit, _, - or /, nor a . or : followed by a letter or digit. White space in a display name matches any run of white space.",
"before": "[\\p{L}\\p{N}_\\-/.:]",
"after": "[\\p{L}\\p{N}_\\-/]|[.:][\\p{L}\\p{N}]"
},
"setting": {
"about": "How the agents {min, max} setting (team.md §2) is shown to a person: one range from 1 to the cap with two handles, `min` and `max` naming them; `label` beside it, `hint` (or `short` where one line has no room) saying what the numbers count — the session's own agent included, so 1 is that agent alone and Max 4 is it and up to 3 subagents; and `own` beside it when the session's model cannot serve subagents itself (subagents.md §4).",
"label": "Subagents",
"min": "Min",
"max": "Max",
"hint": "The count includes this agent: 1 is this agent alone, with no subagents; Max 4 is this agent and up to 3 subagents.",
"short": "1 is this agent alone",
"own": "This model serves one request at a time: subagents need another model, on another server, named in the chat."
}
},
"team": { "team": {
"about": "Teams (spec 3.2.0, team.md): a lead and the members it adds, who talk in the team's room. The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json.", "about": "Teams (spec 3.2.0, team.md; which model a member runs on and when a team is offered: spec 3.3.0, subagents.md): 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.",
"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,
@@ -83,7 +105,7 @@
"report": "[Team report from {{from}} to {{to}}:]" "report": "[Team report from {{from}} to {{to}}:]"
}, },
"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."
}, },
@@ -203,7 +225,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}}.",
@@ -219,6 +241,7 @@
"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 a team from the start: you lead, and members do the work — at least {{members}} of them.",
"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)"
}, },
+3 -3
View File
@@ -21,11 +21,11 @@
"notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." }, "notes": { "type": "string", "maxLength": 300, "description": "What the model is good at, in a line: other models are told about it in the roster, for handing work over." },
"capacity": { "capacity": {
"type": "object", "type": "object",
"description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.", "description": "How much the model's server can do at once, so a subagent on it is not started where it would evict or wait for the model already working (subagents.md §3).",
"properties": { "properties": {
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." }, "group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one, so a subagent on another model of the session's model's group is refused (spec 3.3.0, subagents.md §3). The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." }, "single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." },
"parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. A team (team.md) is offered only on a model that serves at least 2." } "parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. The session's model is its own subagents' model only when it serves at least 2; a one-request session needs a named model on another server (spec 3.3.0, subagents.md §3, §4)." }
}, },
"additionalProperties": false "additionalProperties": false
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of <!-- LLeMbas CLI: the subagent's standing, said once at the end of its system prompt in place of
the mode text; LLeMbas's helper says the same. The variables are what differs per run. --> the mode text; LLeMbas's subagent says the same. The variables are what differs per run. -->
You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}} You are the "{{name}}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask the user anything. {{instructions}}
Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}} Your final reply is your report to the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.{{report}}{{read_only}}
+90
View File
@@ -0,0 +1,90 @@
# Subagents
Spec 3.3.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.
+32 -18
View File
@@ -1,6 +1,6 @@
# Teams # Teams
Spec 3.2.0. **Subagents that work together.** A session's agent — the **lead** — may add Spec 3.2.0; the model a member runs on and when a team is offered, 3.3.0 (`subagents.md`). **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
@@ -29,6 +29,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 +48,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 +82,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 +100,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 +119,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 +183,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 +192,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 +211,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
+2 -2
View File
@@ -12,7 +12,7 @@
"subagent_run" "subagent_run"
] ]
}, },
"description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- Agents: {{agents}}", "description": "Hand a self-contained piece of work to a subagent, which works on its own and returns only its final report — keeping the searching and reading out of your context.\n\n- Use it for broad research (\"where is X handled, and what calls it?\") or a separable job. For one file you already know, just read it.\n- `prompt` must stand on its own: the subagent sees nothing of this conversation. Say what to find or do, and what to report back.\n- `background: true` returns at once with a job id and you carry on; the report comes to you as a message when it ends. Meanwhile job_output shows its progress, job_send gives it a message, job_stop stops it. Without it, the call waits for the report.\n- Independent tasks can run in the same turn. Subagents that change files at the same time should each have `worktree: true`, so they work on separate branches.\n- It runs on your model. Another model only when the person named it in this conversation; nothing else chooses one.\n- Agents: {{agents}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -38,7 +38,7 @@
"type": "boolean" "type": "boolean"
}, },
"model": { "model": {
"description": "Run it on another configured model (connection/model, from the list in your instructions) — a second opinion, or a model better at this kind of work. Default: the agent's own model, else yours.", "description": "Run it on another model — only one the person named in this conversation (its id or name, as in the list in your instructions). Default: your model.",
"type": "string" "type": "string"
} }
}, },
+2 -2
View File
@@ -14,7 +14,7 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}", "description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have, you included; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -47,7 +47,7 @@
}, },
"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."
} }
}, },
"required": [ "required": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lembas-cli", "name": "lembas-cli",
"version": "2.0.0-beta.4", "version": "2.0.0-beta.5",
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.", "description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
+3 -3
View File
@@ -894,17 +894,17 @@
] ]
}, },
"agents": { "agents": {
"description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.", "description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session.",
"type": "object", "type": "object",
"properties": { "properties": {
"min": { "min": {
"description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.", "description": "Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
}, },
"max": { "max": {
"description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.", "description": "Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
+2 -2
View File
@@ -210,11 +210,11 @@
"maxLength": 300 "maxLength": 300
}, },
"single_session": { "single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.", "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1.",
"type": "boolean" "type": "boolean"
}, },
"parallel": { "parallel": {
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.", "description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2.",
"type": "integer", "type": "integer",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 9007199254740991 "maximum": 9007199254740991
+28 -7
View File
@@ -16,6 +16,7 @@ import type { Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts" import { hardlineRules } from "./permission/hardline.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts" import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { subagentModel, type ModelInfo } from "./team/subagents.ts"
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts" import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
import type { Changing } from "./session/prefix.ts" import type { Changing } from "./session/prefix.ts"
import { describeRepo, repoState } from "./git/repo.ts" import { describeRepo, repoState } from "./git/repo.ts"
@@ -202,6 +203,11 @@ 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))
@@ -473,13 +479,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
@@ -509,7 +524,7 @@ 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 team member also has team_send and team_read, and one that may lead team_add and team_stop
// (team.md §4); a task helper has none of them. // (team.md §4); a task subagent has none of them.
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : [] const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
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) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
@@ -597,12 +612,12 @@ 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) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
const handle: SubagentHandle = { const handle: SubagentHandle = {
@@ -683,6 +698,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,6 +710,7 @@ 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),
}) })
@@ -711,7 +729,10 @@ export function createApp(o: AppOptions): App {
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 }
} }
+5 -5
View File
@@ -60,8 +60,8 @@ export const ModelSpec = z.strictObject({
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."), headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."), body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."), notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."), single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1."),
parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."), parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2."),
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."), group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
}) })
export type ModelSpec = z.infer<typeof ModelSpec> export type ModelSpec = z.infer<typeof ModelSpec>
@@ -298,12 +298,12 @@ export const Config = z.strictObject({
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."), effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
agents: z agents: z
.strictObject({ .strictObject({
min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."), min: z.number().int().min(1).optional().describe("Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start."),
max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."), max: z.number().int().min(1).optional().describe("Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents."),
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."), cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
}) })
.optional() .optional()
.describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."), .describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session."),
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."), subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
+8 -2
View File
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { parse, parseDocument } from "yaml" import { parse, parseDocument } from "yaml"
import { z } from "zod" import { z } from "zod"
import LOOP_JSON from "../../harness/loop.json"
import { paths } from "./paths.ts" import { paths } from "./paths.ts"
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts" import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
@@ -29,8 +30,13 @@ export interface SettingDef {
reload?: boolean reload?: boolean
/** What it is when nothing sets it. */ /** What it is when nothing sets it. */
fallback?: string fallback?: string
/** What a person reads beside the key in /settings. */
hint?: string
} }
// The Subagents setting's names and what its numbers count (harness loop.json subagents.setting).
const SUB = LOOP_JSON.subagents.setting
const ALL: Scope[] = ["session", "global", "project"] const ALL: Scope[] = ["session", "global", "project"]
const USER: Scope[] = ["session", "global"] const USER: Scope[] = ["session", "global"]
@@ -38,8 +44,8 @@ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL }, { key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" }, { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
// The team's size is the person's: the agent never sets how many agents it may have. // The team's size is the person's: the agent never sets how many agents it may have.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" }, { key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1", hint: `${SUB.label}: ${SUB.min} — ${SUB.short}` },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" }, { key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4", hint: `${SUB.label}: ${SUB.max} — this agent included; 4 is this agent and up to 3 subagents` },
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
+9
View File
@@ -118,6 +118,13 @@ 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 }
@@ -165,6 +172,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
+1
View File
@@ -13,6 +13,7 @@ export interface AgentSpec {
name: string name: string
description: string description: string
instructions: string instructions: string
/** Read, and not used for a subagent (spec 3.3.0, subagents.md §2): shown so a file that sets it says so. */
model?: string model?: string
/** A fixed permission mode; absent = the parent's. */ /** A fixed permission mode; absent = the parent's. */
mode?: Mode mode?: Mode
+1 -1
View File
@@ -345,7 +345,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>`)
+15 -15
View File
@@ -20,21 +20,21 @@ export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
return m.spec.single_session ? 1 : Infinity return m.spec.single_session ? 1 : Infinity
} }
/** `busy`: this session's requests the helper's model already serves besides the main prompt's /** `busy`: this session's requests the sub's model already serves besides the main prompt's
* (subagents running on it). */ * (subagents running on it). */
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string { export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string {
if (helper.ref === main.ref) { if (sub.ref === main.ref) {
const n = parallelOf(helper) const n = parallelOf(sub)
if (n === 1) if (n === 1)
return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` return `${sub.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
if (1 + busy >= n) if (1 + busy >= n)
return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.` return `${sub.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.`
return "" return ""
} }
const shared = groupOf(main) const shared = groupOf(main)
if (shared !== undefined && shared === groupOf(helper)) { if (shared !== undefined && shared === groupOf(sub)) {
const where = `${main.ref}'s server (group ${main.spec.group})` const where = `${main.ref}'s server (group ${main.spec.group})`
return `${where} holds one model at a time, so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.` return `${where} holds one model at a time, so a subagent on ${sub.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
} }
return "" return ""
} }
@@ -42,17 +42,17 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy
/** The same rule between two subagents: a new one is not started where its model would unload, or /** The same rule between two subagents: a new one is not started where its model would unload, or
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent * take the only slot of, a subagent already running (the spec's "beside the model of a subagent
* already running"). */ * already running"). */
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string { export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
if (helper.ref === running.ref) { if (sub.ref === running.ref) {
const n = parallelOf(helper) const n = parallelOf(sub)
if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.` if (n === 1) return `${sub.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.`
if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.` if (busy >= n) return `${sub.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
return "" return ""
} }
const shared = groupOf(running) const shared = groupOf(running)
if (shared !== undefined && shared === groupOf(helper)) { if (shared !== undefined && shared === groupOf(sub)) {
const where = `${running.ref}'s server (group ${running.spec.group})` const where = `${running.ref}'s server (group ${running.spec.group})`
return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${helper.ref} would unload it.` return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${sub.ref} would unload it.`
} }
return "" return ""
} }
+12 -7
View File
@@ -17,6 +17,7 @@ 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"
export type CommandKind = "custom" | "skill" | "builtin" export type CommandKind = "custom" | "skill" | "builtin"
@@ -48,7 +49,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 team and the room; how many; stop one", hint: "size <min> <max> | stop <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 +57,7 @@ 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` : ""}`
} }
/** `/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 +66,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 +81,7 @@ 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 === "stop") { if (verb === "stop") {
const name = rest[0] const name = rest[0]
@@ -88,9 +90,12 @@ 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}`
return `${t.read("lead", since)}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a member.${own}`
} }
/** `/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 +105,7 @@ export function agentsText(app: App, arg: string): string {
const describe = (a: (typeof list)[number]) => const describe = (a: (typeof list)[number]) =>
[ [
`${a.name} (${a.source}) — ${a.description}`, `${a.name} (${a.source}) — ${a.description}`,
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, ` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
].join("\n") ].join("\n")
const name = arg.trim().split(/\s+/)[0] const name = arg.trim().split(/\s+/)[0]
if (name) { if (name) {
+39 -13
View File
@@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images }) if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0) const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
const reported = engine.usage ? engine.contextUsed() : undefined const reported = engine.usage ? engine.contextUsed() : undefined
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest } // Where automatic compaction starts (EngineOptions compaction.autoAt): the reference's "N until
// auto-compact". None without a window, or when it is switched off (1 or more).
const autoAt = engine.o.compaction?.autoAt ?? 0.85
return { rows, estimated, reported, window: contextWindow(engine.model), autoAt: autoAt < 1 ? autoAt : undefined, model: engine.model.ref, last: engine.lastRequest }
} }
/** Plain text, for the transcript. */ /** One glyph per category in the segmented bar, in the order the categories are listed. */
const SEGMENTS = ["█", "▓", "▒", "░", "▞", "▚", "▙", "▟"]
/** Plain text, for the transcript: what is used of the window and how much, one segmented bar of
* every category, each category with its tokens and its share of the window, and how far it is to
* automatic compaction (/compact compacts now). */
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string { export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
const width = 24 const width = 40
const total = b.window ?? Math.max(b.estimated, b.reported ?? 0)
const bar = (n: number) => {
const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0
return "█".repeat(Math.min(width, cells)).padEnd(width, "·")
}
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`]
for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`)
const used = b.reported ?? b.estimated const used = b.reported ?? b.estimated
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`) const total = b.window ?? Math.max(b.estimated, used)
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)") const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const pct = (n: number) => (b.window ? `${Math.round((n / b.window) * 100)}%` : "")
const cats = b.rows.filter((r) => !r.indent)
// The categories' shares of the bar, scaled to what is in use (the server's count when it gave one).
const scale = b.estimated ? used / b.estimated : 1
let bar = ""
cats.forEach((r, i) => {
const cells = total && r.tokens > 0 ? Math.max(1, Math.round(((r.tokens * scale) / total) * width)) : 0
bar += SEGMENTS[i % SEGMENTS.length]!.repeat(cells)
})
bar = bar.slice(0, width).padEnd(width, "·")
const lines = [
`context — ${b.model}: ${fmt(used)}${b.window ? ` / ${fmt(b.window)} tokens · ${pct(used)} used` : " tokens, window unknown"}`,
` ${bar}`,
]
let i = 0
for (const r of b.rows) {
const mark = r.indent ? " ↳" : ` ${SEGMENTS[i++ % SEGMENTS.length]} `
lines.push(`${mark} ${r.label.padEnd(r.indent ? 30 : 31)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : pct(r.tokens * scale).padStart(4)}`.trimEnd())
}
if (b.window) lines.push(` · ${"free".padEnd(31)} ${fmt(Math.max(0, b.window - used)).padStart(7)} ${pct(Math.max(0, b.window - used)).padStart(4)}`)
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${pct(used).padStart(4)}`.trimEnd())
if (b.window && b.autoAt !== undefined) {
const until = Math.round(b.window * b.autoAt) - used
lines.push(until > 0 ? ` ${fmt(until)} until auto-compact (at ${Math.round(b.autoAt * 100)}%) — /compact to compact now` : ` past auto-compact (${Math.round(b.autoAt * 100)}%): the next step compacts — /compact to compact now`)
} else lines.push(b.window ? " automatic compaction is off — /compact to compact now" : " no automatic compaction without a window — /compact to compact now")
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since; the categories are estimates scaled to it)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with // What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
// little of it cached was a re-read. // little of it cached was a re-read.
const last = b.last?.usage const last = b.last?.usage
+8
View File
@@ -22,6 +22,7 @@ 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 { 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 {
@@ -611,6 +612,13 @@ 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 personTexts(all, this.o.steerMark ?? STEER_MARK)
}
/** `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"> {
+101
View File
@@ -0,0 +1,101 @@
// Subagents (harness spec 3.3.0, harness/subagents.md): which model a subagent — a task subagent or
// a team's member — runs on, and whether subagents are offered at all. The rules alone, without an
// engine: the conformance area `subagents` drives them, and the app (spawn, team_add) asks them.
import { LOOP } from "../harness.ts"
import type { Message } from "../provider/types.ts"
import { parallelFrom, type Capacity } from "./team.ts"
export const SUBAGENTS = LOOP.subagents
const TEXTS = SUBAGENTS.texts
/** A model the person may use: its served id (the ref), its id at its connection, its display name. */
export interface ModelInfo {
ref: string
id?: string
name?: string
capacity: Capacity
}
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const BEFORE = new RegExp(`${SUBAGENTS.named.before}$`, "u")
const AFTER = new RegExp(`^(?:${SUBAGENTS.named.after})`, "u")
/** The names a model goes by: the served id, the part after its provider, the display name. */
export function namesOf(m: Pick<ModelInfo, "ref" | "id" | "name">): string[] {
const out = [m.ref]
if (m.id) out.push(m.id)
const slash = m.ref.lastIndexOf("/")
if (slash >= 0) out.push(m.ref.slice(slash + 1))
if (m.name?.trim()) out.push(m.name.trim())
return [...new Set(out.filter(Boolean))]
}
/** Whether `name` occurs in `text` as a whole word, without case (subagents.md §2). White space in
* the name matches any run of white space. */
export function occursAsWord(text: string, name: string): boolean {
const pattern = name
.trim()
.split(/\s+/)
.map(escape)
.join("\\s+")
if (!pattern) return false
const re = new RegExp(pattern, "giu")
for (let m = re.exec(text); m; m = re.exec(text)) {
const before = text.slice(0, m.index)
const after = text.slice(m.index + m[0].length)
if (!BEFORE.test(before) && !AFTER.test(after)) return true
re.lastIndex = m.index + 1
}
return false
}
/** Whether the person named the model in any of the messages they typed. */
export function named(m: Pick<ModelInfo, "ref" | "id" | "name">, said: readonly string[]): boolean {
const names = namesOf(m)
return said.some((t) => names.some((n) => occursAsWord(t, n)))
}
/** What the person typed in a session, oldest first: their prompts and what they sent mid-turn
* (after `steerMark`) — never a note, a job's or a team's marked message, or a machine turn. */
export function personTexts(messages: readonly Message[], steerMark: string): string[] {
const out: string[] = []
for (const m of messages) {
if (m.role !== "user" || m.machine) continue
const first = m.parts[0]
if (!first || first.type !== "text") continue
if (m.turn === false) {
if (first.text.startsWith(steerMark)) out.push(first.text.slice(steerMark.length).trim())
continue
}
out.push(first.text)
}
return out
}
/** Whether a model can be its own session's subagent: it serves two requests at once, or has no limit. */
export function servesOwn(c: Capacity): boolean {
return parallelFrom(c) >= 2
}
/** subagents.md §4: whether subagents are offered for a session on `session`, given the models the
* person may use, and whether the session's model can serve its own. */
export function offer(session: ModelInfo, models: readonly ModelInfo[], subagents = true): { offered: boolean; own: boolean } {
const own = servesOwn(session.capacity)
const group = session.capacity.group
const other = models.some((m) => m.ref !== session.ref && (group === undefined || m.capacity.group !== group))
return { offered: subagents && (own || other), own }
}
/** subagents.md §2, §3: the model a subagent runs on — the session's, or `wanted` (already resolved
* to a model the person may use) — or the refusal, word for word. */
export function subagentModel(session: ModelInfo, wanted: ModelInfo | undefined, said: readonly string[]): { model: string } | { refused: string } {
const m = wanted ?? session
if (m.ref !== session.ref) {
if (!named(m, said)) return { refused: TEXTS.not_named }
const group = session.capacity.group
if (group !== undefined && m.capacity.group === group) return { refused: TEXTS.same_group }
return { model: m.ref }
}
if (!servesOwn(session.capacity)) return { refused: TEXTS.one_request }
return { model: session.ref }
}
+37 -24
View File
@@ -86,6 +86,9 @@ 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
@@ -111,10 +114,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. */
@@ -152,7 +155,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 +168,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 +219,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 +234,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). */
@@ -259,14 +272,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 +312,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 ────────────────────────────────────────────────────────────────
@@ -545,7 +558,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
+41 -20
View File
@@ -11,11 +11,12 @@ 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 { 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 +86,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 +96,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 +167,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 +236,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 })
@@ -242,7 +263,7 @@ export const COMMANDS: Command[] = [
{ {
name: "team", name: "team",
args: "[size [<min> <max>] | stop [<name>]]", args: "[size [<min> <max>] | stop [<name>]]",
description: "the team: its agents and the room; its size; stop a member", description: "subagents: the team and the room; how many; stop one",
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)
@@ -1006,7 +1027,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 +1043,7 @@ export const COMMANDS: Command[] = [
options: S.list().map((e) => ({ options: S.list().map((e) => ({
label: e.def.key, label: e.def.key,
value: e.def.key, value: e.def.key,
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}`, description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
})), })),
onSelect: askValue, onSelect: askValue,
}) })
+68 -1
View File
@@ -8,6 +8,31 @@ export interface Option {
value: string value: string
description?: string description?: string
current?: boolean current?: boolean
/** A two-handle range on this row (the Subagents setting, harness team.md §2: one range from 1
* to the cap, Min and Max). While the row is highlighted ←→ move the handle in focus and tab
* moves the focus; Min never passes Max. Enter calls `onSubmit` with both, not `onSelect`. */
range?: Range
}
export interface Range {
/** The bounds of the track. */
from: number
to: number
/** The handles' values. */
lo: number
hi: number
/** The handles' names (Min, Max). */
names: [string, string]
onSubmit: (lo: number, hi: number) => void
}
/** The track as text: `─` outside the range, `━` inside it, `●` for a handle. */
export function rangeTrack(r: Pick<Range, "from" | "to">, lo: number, hi: number, width = 16): { before: string; lo: string; between: string; hi: string; after: string } {
const span = Math.max(1, r.to - r.from)
const at = (v: number) => Math.round(((v - r.from) / span) * (width - 1))
const a = at(lo)
const b = Math.max(at(hi), a + (hi > lo ? 1 : 0))
return { before: "─".repeat(a), lo: "●", between: "━".repeat(Math.max(0, b - a - 1)), hi: b > a ? "●" : "", after: "─".repeat(Math.max(0, width - 1 - b)) }
} }
export interface DialogSpec { export interface DialogSpec {
@@ -45,6 +70,16 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
}) })
// A range row's handles, moved while the dialog is open and handed over on enter; esc drops them.
const [ranges, setRanges] = createSignal<Record<string, [number, number]>>({})
const [focus, setFocus] = createSignal<0 | 1>(1)
const valuesOf = (o: Option): [number, number] => ranges()[o.value] ?? [o.range!.lo, o.range!.hi]
const move = (o: Option, by: number) => {
const r = o.range!
const [lo, hi] = valuesOf(o)
const next: [number, number] = focus() === 0 ? [Math.min(hi, Math.max(r.from, lo + by)), hi] : [lo, Math.min(r.to, Math.max(lo, hi + by))]
setRanges((m) => ({ ...m, [o.value]: next }))
}
const start = Math.max(0, p.spec.options.findIndex((o) => o.current)) const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
const [sel, setSel] = createSignal(start) const [sel, setSel] = createSignal(start)
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row. // The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
@@ -70,6 +105,8 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
run(o.value) run(o.value)
} }
} else if (key.name === "escape") p.close() } else if (key.name === "escape") p.close()
else if (shown()[sel()]?.range && (key.name === "left" || key.name === "right")) move(shown()[sel()]!, key.name === "left" ? -1 : 1)
else if (shown()[sel()]?.range && key.name === "tab") setFocus((f) => (f === 0 ? 1 : 0))
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1)) else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1)) else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
else if (key.name === "return") { else if (key.name === "return") {
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const onSelect = p.spec.onSelect const onSelect = p.spec.onSelect
const custom = p.spec.custom const custom = p.spec.custom
const typed = query().trim() const typed = query().trim()
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
p.close() p.close()
if (o.value === CUSTOM) custom?.onSubmit(typed) if (o.value === CUSTOM) custom?.onSubmit(typed)
else if (range) range.submit(...range.values)
else onSelect(o.value) else onSelect(o.value)
} }
} else if (filterable() && key.name === "backspace") { } else if (filterable() && key.name === "backspace") {
@@ -104,6 +143,30 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<For each={shown().slice(offset(), offset() + visible())}> <For each={shown().slice(offset(), offset() + visible())}>
{(o, i) => { {(o, i) => {
const active = () => i() + offset() === sel() const active = () => i() + offset() === sel()
if (o.range) {
const values = () => valuesOf(o)
const track = () => rangeTrack(o.range!, values()[0], values()[1])
const handle = (n: 0 | 1) => (active() && focus() === n ? c().accent : active() ? c().title : c().text)
return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
{" "}
{o.label}
{" "}
<span style={{ fg: handle(0) }}>{`${o.range.names[0]} ${values()[0]}`}</span>
{" "}
<span style={{ fg: c().dim }}>{track().before}</span>
<span style={{ fg: handle(0) }}>{track().lo}</span>
<span style={{ fg: c().accent }}>{track().between}</span>
<span style={{ fg: handle(1) }}>{track().hi}</span>
<span style={{ fg: c().dim }}>{track().after}</span>
{" "}
<span style={{ fg: handle(1) }}>{`${o.range.names[1]} ${values()[1]}`}</span>
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
</text>
</box>
)
}
return ( return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}> <box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}> <text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
@@ -118,7 +181,11 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<Show when={shown().length === 0}> <Show when={shown().length === 0}>
<text fg={c().muted}>nothing matches</text> <text fg={c().muted}>nothing matches</text>
</Show> </Show>
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text> <text fg={c().dim}>
{shown()[sel()]?.range
? `←→ move ${shown()[sel()]!.range!.names[focus()]} · tab ${shown()[sel()]!.range!.names[focus() === 0 ? 1 : 0]} · enter set · esc close`
: (p.spec.hint ?? "↑↓ move · enter choose · esc close")}
</text>
</box> </box>
</box> </box>
) )
+1 -1
View File
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
], { route: (model) => (model.startsWith("g/") ? child : undefined) }) ], { route: (model) => (model.startsWith("g/") ? child : undefined) })
config(fake.url, true) config(fake.url, true)
const { c, events, sid } = await client() const { c, events, sid } = await client()
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] }) await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go (a subagent on g/m)" }] })
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true) expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
const listed: any = await c.request("_lembas/jobs", { sessionId: sid }) const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" }) expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
+30 -3
View File
@@ -51,10 +51,37 @@ test("task on a model the server cannot hold beside the session's is refused, an
) )
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n") writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } }) const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("get a second opinion") await a.engine.prompt("get a second opinion from f/other")
// Two requests: the session's, then its next step — never one for f/other. // Two requests: the session's, then its next step — never one for f/other.
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"]) expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
const result = fake.requests[1].messages.at(-1).content as string const result = fake.requests[1].messages.at(-1).content as string
expect(result).toContain("holds one model at a time") expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
expect(result).toContain("Do this part yourself") })
test("spec 3.3.0: task on another model the person did not name is refused, and nothing runs", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "second opinion", prompt: "look at it", model: "f/other" }))] },
{ chunks: [delta({ content: "I will do it myself." })] },
])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: {}, f/other: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("get a second opinion")
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
expect(fake.requests[1].messages.at(-1).content as string).toContain("Use another model for subagents only when the person names it.")
})
test("spec 3.3.0: a one-request session model is not its own subagent, and nothing else is picked", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "look", prompt: "look at it" }))] },
{ chunks: [delta({ content: "I will do it myself." })] },
])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: { parallel: 1 }, f/other: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await a.engine.prompt("look into it")
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
expect(fake.requests[1].messages.at(-1).content as string).toContain("This model serves one request at a time: name another model, on another server, for subagents.")
}) })
+25 -4
View File
@@ -27,9 +27,10 @@ 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 { 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"
type Case = Record<string, unknown> type Case = Record<string, unknown>
@@ -233,6 +234,26 @@ 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) => {
@@ -265,15 +286,15 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
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 { 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
+2 -1
View File
@@ -87,7 +87,8 @@ describe("context", () => {
await a.engine.prompt("read it") await a.engine.prompt("read it")
const system = a.engine.compose(a.engine.mode) const system = a.engine.compose(a.engine.mode)
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools)) const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
expect(text).toContain("window 32.8k") expect(text).toContain("/ 32.8k tokens")
expect(text).toMatch(/until auto-compact \(at 85%\) — \/compact to compact now/)
expect(text).toContain("system prompt") expect(text).toContain("system prompt")
expect(text).toMatch(/tool definitions \(\d+\)/) expect(text).toMatch(/tool definitions \(\d+\)/)
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
+6 -6
View File
@@ -197,7 +197,7 @@ describe("background subagents (spec 2.0.0)", () => {
], ],
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }], [{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
) )
await a.turns.prompt("look into it in the background") await a.turns.prompt("look into it in the background (subagents on g/m or g/n)")
const started = fake!.requests[1].messages.at(-1).content as string const started = fake!.requests[1].messages.at(-1).content as string
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).") expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
await settled(a, 3) await settled(a, 3)
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
{ chunks: [delta({ content: "Second report." })] }, { chunks: [delta({ content: "Second report." })] },
], ],
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1") expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
await settled(a, 6) await settled(a, 6)
const child = other!.requests const child = other!.requests
@@ -252,7 +252,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }], [{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }], [{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
const msgs = fake!.requests[1].messages const msgs = fake!.requests[1].messages
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message)) const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?") expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
@@ -278,7 +278,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }], [{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
{ asker }, { asker },
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }]) expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
}) })
@@ -297,7 +297,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }], [{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
{ oneAtATime: true }, { oneAtATime: true },
) )
await a.turns.prompt("go") await a.turns.prompt("go (subagents on g/m or g/n)")
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string) const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1") expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there") expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
@@ -330,7 +330,7 @@ test("a cancelled worktree subagent keeps its branch, and the result names it",
const root = a.project.root const root = a.project.root
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args) for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
;(a.project as any).gitRoot = root ;(a.project as any).gitRoot = root
const turn = a.turns.prompt("fix it in a worktree") const turn = a.turns.prompt("fix it in a worktree (subagents on g/m or g/n)")
const until = Date.now() + 8000 const until = Date.now() + 8000
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20) while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
const write = events.find((e) => e.type === "sub_tool" && e.name === "write") const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
+33
View File
@@ -0,0 +1,33 @@
// Spec 3.3.0 (harness/subagents.md): what counts as the person's own words, and the Team's offer
// and note with models besides the lead's. The rules themselves: conformance area `subagents`.
import { expect, test } from "bun:test"
import { LOOP } from "../src/harness.ts"
import { STEER_MARK } from "../src/session/engine.ts"
import { named, personTexts } from "../src/team/subagents.ts"
import { Team, type Capacity } from "../src/team/team.ts"
import type { Message } from "../src/provider/types.ts"
test("the person's words: prompts and messages sent mid-turn; never notes, marked messages, machine turns or the model's text", () => {
const msgs: Message[] = [
{ role: "user", parts: [{ type: "text", text: "Fix the parser." }, { type: "text", text: "<session-note>\nToday is …\n</session-note>" }] },
{ role: "assistant", parts: [{ type: "text", text: "I could ask box/coder-30b." }] },
{ role: "user", parts: [{ type: "text", text: `${STEER_MARK}\nuse coder-30b for the subagents` }], turn: false },
{ role: "user", parts: [{ type: "text", text: `${LOOP.jobs.marks.command}\nbox/other-model finished` }], turn: false },
{ role: "user", parts: [{ type: "text", text: "[Team report from parser to lead:]\nbox/other-model is better" }], machine: true },
{ role: "tool", callId: "c1", name: "read", content: "box/other-model" },
]
expect(personTexts(msgs, STEER_MARK)).toEqual(["Fix the parser.", "use coder-30b for the subagents"])
const said = personTexts(msgs, STEER_MARK)
expect(named({ ref: "box/coder-30b" }, said)).toBe(true)
expect(named({ ref: "box/other-model" }, said)).toBe(false)
})
test("a one-request lead with a model on another server: offered, and the size note says it cannot serve its own", () => {
const caps: Record<string, Capacity> = { "lab/qwen": { group: "lab", parallel: 1 }, "lab/gemma": { group: "lab", parallel: 1 }, "box/coder": { parallel: 1 } }
const only = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => ["lab/qwen", "lab/gemma"] })
expect(only.offered).toBe(false)
const t = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => Object.keys(caps) })
expect(t.offered).toBe(true)
expect(t.unavailable()).toBeUndefined()
expect(t.noteLines()).toEqual([`The team may have 1 to 4 agents, you included.\n${LOOP.context.note.team_own}`])
})
+4 -4
View File
@@ -94,7 +94,7 @@ 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 — 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.
@@ -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.")
+47
View File
@@ -36,3 +36,50 @@ test("dialog: arrow down + enter picks the second option — with a spec that cl
expect(picked).toEqual(["b"]) expect(picked).toEqual(["b"])
expect(closed).toBe(1) expect(closed).toBe(1)
}) })
test("dialog: a range row is one track with two handles — ←→ move the one in focus, tab the other, enter hands both over", async () => {
const set: [number, number][] = []
const picked: string[] = []
const ctx = { theme: () => DEFAULT_THEME, dims: () => ({ width: 72, height: 16 }) } as never
const [spec, setSpec] = createSignal<DialogSpec | undefined>({
title: "Effort",
options: [
{ label: "high", value: "high", current: true },
{ label: "Subagents", value: "team", description: "1 is this agent alone", range: { from: 1, to: 32, lo: 1, hi: 4, names: ["Min", "Max"], onSubmit: (lo, hi) => set.push([lo, hi]) } },
],
onSelect: (v) => picked.push(v),
})
setup = await testRender(
() => (
<TuiContext.Provider value={ctx}>
<Show when={spec()}>
<Dialog spec={spec()!} close={() => setSpec(undefined)} />
</Show>
</TuiContext.Provider>
),
{ width: 72, height: 16 },
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Subagents Min 1 ●")
setup.mockInput.pressArrow("down")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("←→ move Max · tab Min")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Max 6")
setup.mockInput.pressTab()
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("left")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 2")
// Min never passes Max, and Min never goes under 1.
for (let i = 0; i < 10; i++) setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 6")
setup.mockInput.pressEnter()
await setup.renderOnce()
expect(set).toEqual([[6, 6]])
expect(picked).toEqual([])
})