Compare commits
8
Commits
v2.0.0-beta.4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0e0ed145
|
||
|
|
3a206959b6
|
||
|
|
6993937522
|
||
|
|
f88921667e
|
||
|
|
0c3596d044
|
||
|
|
6e0582dc22
|
||
|
|
a26dd8a5e0
|
||
|
|
02c7282e74
|
No files matched your search
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
3.2.0
|
||||
3.3.0
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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)"
|
||||
},
|
||||
|
||||
@@ -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,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}}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
Reference in new issue
Block a user