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
[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
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/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `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 |
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
| `subagents.md` | Subagents: the word, which model one runs on (the session's, or one the person named), the capacity refusals, and when the Subagents setting is offered |
| `quirks.json` | What real servers do, and how both harnesses cope |
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
@@ -33,7 +34,7 @@ page *Harness-parity* in LLeMbas CLI's wiki.
## Vocabulary
- **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`).
- **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
@@ -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
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
plan mode; `team` (3.2.0): the session's team setting allows more than one agent, on a model that
serves two requests at once (`team.md` §2). A requirement that goes away between the offer and the call makes the call answer the
plan mode; `team` (3.2.0): the session's team setting allows more than one agent, and a model could
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
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.
@@ -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
`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
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": [
{
"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": {
"min": 1,
"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": {
"min": 1,
"max": 3,
@@ -772,7 +772,7 @@
}
},
{
"op": "helper_done",
"op": "task_done",
"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.",
"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.",
"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": {
"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": {
@@ -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."
},
"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": {
"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": {
"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,
@@ -83,7 +105,7 @@
"report": "[Team report from {{from}} to {{to}}:]"
},
"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_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)"
},
"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>",
"close": "</session-note>",
"date": "Today is {{date}}.",
@@ -219,6 +241,7 @@
"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_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
"team_own": "This model serves one request at a time, so subagents cannot run on it: they need another model, on another server, that the person names in this chat.",
"team_stopped": "The person stopped {{names}}; they have left the team.",
"empty": "(none)"
},
+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." },
"capacity": {
"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": {
"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." },
"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
}
+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
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}}
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
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
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
@@ -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
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),
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
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
`parallel` nor `single_session` is set (a hosted API; `models/schema.json`).
2. **At least one model could serve as a subagent** (spec 3.3.0, `subagents.md` §4): the chat's
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
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.
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and the model still
serves two at once — or, for the lead, when its team still has a member (so a lead whose person
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and a model could
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
met answers, word for word, `team.unavailable.one_request` when the model's capacity no longer
allows a team (its metadata changed after the prompt was made), else `team.unavailable.one_agent`
met answers, word for word, `team.unavailable.one_request` when no model could serve as a subagent
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.
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`).
- **`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.
@@ -72,14 +82,14 @@ The meaning of the setting:
## 3. Size, and what counts
**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).
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.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
`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
is under the new `max`.
- 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
unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its
manager's (`permission/modes.json`).
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only); `model` is
as `task`'s — a model the person may use, the default being the caller's.
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only). **Its model**
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
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
@@ -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`
(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`
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
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),
@@ -171,7 +183,7 @@ class.
**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
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).
## 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
(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
`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.
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
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
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
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
+2 -2
View File
@@ -12,7 +12,7 @@
"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": {
"type": "object",
"properties": {
@@ -38,7 +38,7 @@
"type": "boolean"
},
"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"
}
},
+2 -2
View File
@@ -14,7 +14,7 @@
"cli": [],
"llembas": []
},
"description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; 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": {
"type": "object",
"properties": {
@@ -47,7 +47,7 @@
},
"model": {
"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": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"license": "MIT",
"private": true,
+3 -3
View File
@@ -894,17 +894,17 @@
]
},
"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",
"properties": {
"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",
"minimum": 1,
"maximum": 9007199254740991
},
"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",
"minimum": 1,
"maximum": 9007199254740991
+2 -2
View File
@@ -210,11 +210,11 @@
"maxLength": 300
},
"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"
},
"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",
"exclusiveMinimum": 0,
"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 { hardlineRules } from "./permission/hardline.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 type { Changing } from "./session/prefix.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. */
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
// (src/session/capacity.ts), before anything is made.
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).
if (!member) {
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 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).
let wt: Worktree | undefined
@@ -509,7 +524,7 @@ export function createApp(o: AppOptions): App {
: perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
// A team member also has team_send and team_read, and one that may lead team_add and team_stop
// (team.md §4); a task 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 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))),
@@ -597,12 +612,12 @@ export function createApp(o: AppOptions): App {
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 } : {}) }
}
// 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).
const counted = (p: Promise<SubagentResult>) => {
if (member || !teamRt.team.offered) return p
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
return p.finally(() => teamRt.team.helperDone(id))
const id = teamRt.team.taskStart(TEAM.names.lead, m.ref)
return p.finally(() => teamRt.team.taskDone(id))
}
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
const handle: SubagentHandle = {
@@ -683,6 +698,8 @@ export function createApp(o: AppOptions): App {
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.
const teamSize: { min?: number; max?: number } = {}
const newTeam = () =>
@@ -693,6 +710,7 @@ export function createApp(o: AppOptions): App {
subagents: loaded.config.subagents !== false,
model: engine.model.ref,
capacity: capacityOf,
models: usableModels,
leadState: "idle",
readingBash: (command) => readingBashByDefault(command, project.root),
})
@@ -711,7 +729,10 @@ export function createApp(o: AppOptions): App {
resolve: (agent, model) => {
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
try {
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) {
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."),
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."),
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."),
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."),
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). 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."),
})
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."),
agents: z
.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."),
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."),
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("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)."),
})
.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."),
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."),
+8 -2
View File
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { parse, parseDocument } from "yaml"
import { z } from "zod"
import LOOP_JSON from "../../harness/loop.json"
import { paths } from "./paths.ts"
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
@@ -29,8 +30,13 @@ export interface SettingDef {
reload?: boolean
/** What it is when nothing sets it. */
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 USER: Scope[] = ["session", "global"]
@@ -38,8 +44,8 @@ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL },
{ 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.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" },
{ 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", 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: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ 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 }
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. */
team: {
agents: { min: number; max: number; cap: number }
@@ -165,6 +172,8 @@ export const LOOP: {
team_lead: string
team_one: 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. */
mcp_wait_ms: number
+1
View File
@@ -13,6 +13,7 @@ export interface AgentSpec {
name: string
description: 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
/** A fixed permission mode; absent = the parent's. */
mode?: Mode
+1 -1
View File
@@ -345,7 +345,7 @@ export function assembleSystem(ctx: PromptContext): string {
].join("\n"),
)
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.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
}
/** `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). */
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string {
if (helper.ref === main.ref) {
const n = parallelOf(helper)
export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string {
if (sub.ref === main.ref) {
const n = parallelOf(sub)
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)
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 ""
}
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})`
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 ""
}
@@ -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
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
* already running"). */
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string {
if (helper.ref === running.ref) {
const n = parallelOf(helper)
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 (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.`
export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
if (sub.ref === running.ref) {
const n = parallelOf(sub)
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 `${sub.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.`
return ""
}
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})`
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 ""
}
+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 { CONTINUE_PROMPT } from "./engine.ts"
import { TEAM, Team } from "../team/team.ts"
import { SUBAGENTS } from "../team/subagents.ts"
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: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "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
@@ -56,7 +57,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
export function teamLabel(team: Team): string {
if (!team.hasMembers()) return ""
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
@@ -65,10 +66,11 @@ export function teamLabel(team: Team): string {
export function teamText(app: App, arg: string): string {
const rt = app.team
const t = rt.team
const S = SUBAGENTS.setting
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
if (verb === "size") {
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).
const no = Team.check(min, max, t.cap)
if (no) return no
@@ -79,7 +81,7 @@ export function teamText(app: App, arg: string): string {
} catch (e) {
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") {
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.`
}
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)
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
@@ -100,7 +105,7 @@ export function agentsText(app: App, arg: string): string {
const describe = (a: (typeof list)[number]) =>
[
`${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")
const name = arg.trim().split(/\s+/)[0]
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 })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
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 {
const width = 24
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 width = 40
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)}%` : ""}`)
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 total = b.window ?? Math.max(b.estimated, used)
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
// little of it cached was a re-read.
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 { memoryResults, presentIds, ResultContext } from "./results.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"
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)
}
/** 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.
* `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"> {
+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. */
model: string
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
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */
readingBash?: (command: string) => boolean
@@ -111,10 +114,10 @@ export class Team {
model: string
private agents = new Map<string, TeamAgent>()
room: RoomMessage[] = []
/** `task` helpers running in the team: who started each, and on which model. */
private helpers: { id: number; by: string; model: string }[] = []
/** `task` subagents running in the team: who started each, and on which model. */
private tasks: { id: number; by: string; model: string }[] = []
private seq = 0
private helperSeq = 0
private taskSeq = 0
/** Whether the team is offered, fixed as the prompt is made (§2). */
offered = false
/** 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. */
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
}
@@ -165,13 +168,22 @@ export class Team {
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
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
/** Spec 3.3.0 (subagents.md §4): whether a model could serve as a subagent now — the lead's own,
* 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 {
this.model = model
const lead = this.agents.get(LEAD)
if (lead) lead.model = model
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
this.offered = offered ?? (this.subagents && this.couldServe().any)
return this.offered
}
@@ -207,9 +219,9 @@ export class Team {
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 {
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. */
@@ -222,8 +234,9 @@ export class Team {
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
unavailable(): string | undefined {
if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined
return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent
const could = this.couldServe().any
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). */
@@ -259,14 +272,14 @@ export class Team {
// ── slots ───────────────────────────────────────────────────────────────────
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 {
const group = this.o.capacity(model).group
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 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))
}
@@ -299,22 +312,22 @@ export class Team {
return lead.state === "working"
}
/** A `task` helper started by `by` (it counts and holds a slot until helperDone). */
helperStart(by: string, model = this.model): number {
const id = ++this.helperSeq
this.helpers.push({ id, by, model })
/** A `task` subagent started by `by` (it counts and holds a slot until taskDone). */
taskStart(by: string, model = this.model): number {
const id = ++this.taskSeq
this.tasks.push({ id, by, model })
return id
}
helperDone(id: number) {
this.helpers = this.helpers.filter((h) => h.id !== id)
taskDone(id: number) {
this.tasks = this.tasks.filter((h) => h.id !== id)
this.fillSlots()
}
/** The first helper `by` started, done (the conformance area's `helper_done`). */
helperDoneBy(by: string) {
const h = this.helpers.find((x) => x.by === by)
if (h) this.helperDone(h.id)
/** The first task subagent `by` started, done (the conformance area's `task_done`). */
taskDoneBy(by: string) {
const h = this.tasks.find((x) => x.by === by)
if (h) this.taskDone(h.id)
}
// ── delivery ────────────────────────────────────────────────────────────────
@@ -545,7 +558,7 @@ export class Team {
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
this.told = { min: this.min, max: this.max }
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(", ") }))
return out
+41 -20
View File
@@ -11,11 +11,12 @@ import { doRelease, planRelease } from "../git/release.ts"
import { instructionFiles } from "../prompt/assemble.ts"
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
import { SUBAGENTS } from "../team/subagents.ts"
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.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 { themeNames } from "./theme.ts"
import { render } from "../plan/plan.ts"
@@ -85,7 +86,6 @@ const pendingCodes = new Map<string, (text: string) => void>()
const AGENT_TEMPLATE = (name: string) => `---
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
# 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
@@ -96,23 +96,44 @@ report is all the session's model sees of your work.
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) {
const t = c.app.team.team
const pairs = [
[1, 1, "one agent: no team, no helper"],
[1, 4, "you decide; up to three members"],
[2, 4, "a team from the start"],
[1, 8, "you decide; up to seven members"],
[3, 8, "a team of at least three"],
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
[1, 1, "this agent alone: no subagents"],
[1, 4, "this agent decides; up to three subagents"],
[2, 4, "subagents from the start"],
[1, 8, "this agent decides; up to seven subagents"],
[3, 8, "at least two subagents"],
[1, Math.min(16, t.cap), "many subagents, with sub-managers"],
] as const
const S = SUBAGENTS.setting
const seen = new Set<string>()
const options = pairs
.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 }))
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } })
.map(([min, max, what]) => ({ label: `${S.min} ${min}, ${S.max} ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
c.openDialog({
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[] = [
@@ -146,7 +167,7 @@ export const COMMANDS: Command[] = [
const describe = (a: (typeof list)[number]) =>
[
`${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")
if (named) return c.view.info(describe(named))
c.view.info(
@@ -215,10 +236,10 @@ export const COMMANDS: Command[] = [
c.setEffort(v as Level)
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).
const team = c.app.team.team
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 choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
// The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where
// subagents are offered).
const agentsRow = c.app.team.team.offered ? [teamRange(c)] : []
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 (arg) return set(arg)
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",
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) {
// 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)
@@ -1006,7 +1027,7 @@ export const COMMANDS: Command[] = [
? [...(e.def.values ?? [])]
: []
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) })),
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) },
@@ -1022,7 +1043,7 @@ export const COMMANDS: Command[] = [
options: S.list().map((e) => ({
label: 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,
})
+68 -1
View File
@@ -8,6 +8,31 @@ export interface Option {
value: string
description?: string
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 {
@@ -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
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 [sel, setSel] = createSignal(start)
// 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)
}
} 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 === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
else if (key.name === "return") {
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const onSelect = p.spec.onSelect
const custom = p.spec.custom
const typed = query().trim()
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
p.close()
if (o.value === CUSTOM) custom?.onSubmit(typed)
else if (range) range.submit(...range.values)
else onSelect(o.value)
}
} 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())}>
{(o, i) => {
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 (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<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}>
<text fg={c().muted}>nothing matches</text>
</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>
)
+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) })
config(fake.url, true)
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)
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" })
+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")
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.
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
const result = fake.requests[1].messages.at(-1).content as string
expect(result).toContain("holds one model at a time")
expect(result).toContain("Do this part yourself")
expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
})
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 { envLines, promptText } from "../src/prompt/assemble.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 { noteBlock } from "../src/session/prefix.ts"
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
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)
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
// (team/team.ts); the app drives the same class from real turns.
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 {
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() }
}
case "finish":
team.finish(by, st.reply ?? "", st.reason ?? "stop")
return { delivered: delivered() }
case "helper_done":
team.helperDoneBy(by)
case "task_done":
team.taskDoneBy(by)
return null
case "set":
if (st.capacity) capacity = st.capacity
+2 -1
View File
@@ -87,7 +87,8 @@ describe("context", () => {
await a.engine.prompt("read it")
const system = a.engine.compose(a.engine.mode)
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).toMatch(/tool definitions \(\d+\)/)
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." })] }],
)
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
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
await settled(a, 3)
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
{ 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")
await settled(a, 6)
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, "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 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?")
@@ -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" })] }],
{ 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" }])
})
@@ -297,7 +297,7 @@ describe("background subagents (spec 2.0.0)", () => {
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
{ 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)
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")
@@ -330,7 +330,7 @@ test("a cancelled worktree subagent keeps its branch, and the result names it",
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)
;(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
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")
+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.get("parser")!.state).toBe("finished")
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(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
// 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++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one")
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 3 2")).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(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)
await app.engine.prompt("two")
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(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([])
})